From 110e5e8163493dd9b92cc48177c4cc07f0342a44 Mon Sep 17 00:00:00 2001 From: Rithvik Sriram Date: Wed, 21 Jan 2026 22:48:07 +0100 Subject: [PATCH 01/55] added a Dialog sequence for when the server is not up, which was previously just a log msg. Now the user gets to provide their own server. --- client/src/main/java/client/UI.java | 12 ++- .../scenes/ServerConnectionDialogCtrl.java | 94 ++++++++++++++++ .../java/client/utils/server/ServerUtils.java | 9 +- locc.sh | 102 ++++++++++++++++++ 4 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 client/src/main/java/client/scenes/ServerConnectionDialogCtrl.java create mode 100644 locc.sh diff --git a/client/src/main/java/client/UI.java b/client/src/main/java/client/UI.java index 752dbaf..da3a2b3 100644 --- a/client/src/main/java/client/UI.java +++ b/client/src/main/java/client/UI.java @@ -2,6 +2,7 @@ package client; import client.scenes.FoodpalApplicationCtrl; import client.scenes.MainCtrl; +import client.scenes.ServerConnectionDialogCtrl; import client.utils.server.ServerUtils; import com.google.inject.Injector; import javafx.application.Application; @@ -27,9 +28,14 @@ public class UI extends Application { var serverUtils = INJECTOR.getInstance(ServerUtils.class); if (!serverUtils.isServerAvailable()) { - var msg = "Server needs to be started before the client, but it does not seem to be available. Shutting down."; - System.err.println(msg); - return; + var connectionHandler = INJECTOR.getInstance(ServerConnectionDialogCtrl.class); + boolean serverConnected = connectionHandler.promptForURL(); + + if(!serverConnected){ + var msg = "User Cancelled Server connection. Shutting down"; + System.err.print(msg); + return; + } } var foodpal = FXML.load(FoodpalApplicationCtrl.class, "client", "scenes", "FoodpalApplication.fxml"); diff --git a/client/src/main/java/client/scenes/ServerConnectionDialogCtrl.java b/client/src/main/java/client/scenes/ServerConnectionDialogCtrl.java new file mode 100644 index 0000000..4283d01 --- /dev/null +++ b/client/src/main/java/client/scenes/ServerConnectionDialogCtrl.java @@ -0,0 +1,94 @@ +package client.scenes; + + +import client.utils.ConfigService; +import client.utils.server.ServerUtils; +import com.google.inject.Inject; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.TextInputDialog; + +import java.util.Optional; + + +public class ServerConnectionDialogCtrl { + + private final ConfigService configService; + private final ServerUtils serverUtils; + + + + @Inject + public ServerConnectionDialogCtrl(ConfigService configService, ServerUtils serverUtils) { + this.configService = configService; + this.serverUtils = serverUtils; + + } + + /** + * + * @return a boolean for if the user got connected to server or + */ + public boolean promptForURL(){ + Alert error = new Alert(Alert.AlertType.ERROR); //creates an error alert + error.setTitle("Server is Unavailable"); + error.setHeaderText("Unable to connect to Server"); + error.setContentText("The server at " + configService.getConfig().getServerUrl() + + " is not available\n\n Would you like to try a different Server URL?"); + + error.getButtonTypes().setAll(ButtonType.YES, ButtonType.NO); + Optional userChoice = error.showAndWait(); //asks if user wants to input server URL + + if(userChoice.isEmpty() || userChoice.get() == ButtonType.NO){ + return false; + } + + while(true){ // Keeps asking the user until either a valid url is provided or the user exits + TextInputDialog dialog = + new TextInputDialog(configService.getConfig().getServerUrl()); + dialog.setTitle("Enter new server URL"); + dialog.setContentText("Server URL:"); + Optional userRes = dialog.showAndWait(); + if(userRes.isEmpty()){ + return false; //user cancelled the operation + } + String newServer = userRes.get().trim(); + + if(newServer.isEmpty()){ + Alert alert = new Alert(Alert.AlertType.WARNING); + alert.setTitle("Invalid Input"); + alert.setHeaderText("Invalid server URL"); + alert.setContentText("Please enter a valid URL"); + alert.showAndWait(); + continue; + } + configService.getConfig().setServerUrl(newServer); + if(serverUtils.isServerAvailable()){ + configService.save(); + Alert success = new Alert(Alert.AlertType.INFORMATION); + success.setTitle("Success"); + success.setHeaderText("Connected to Server"); + success.setContentText("Successfully connected to the server!"); + success.showAndWait(); + return true; + } + else{ + Alert retry = new Alert(Alert.AlertType.ERROR); + retry.setTitle("Failed"); + retry.setHeaderText("Failed to connect to Server"); + retry.setContentText("Would you like to try another URL?"); + retry.getButtonTypes().setAll(ButtonType.YES, ButtonType.NO); + Optional result = retry.showAndWait(); + if(result.isEmpty() || result.get() == ButtonType.NO){ + return false; + } + + } + + } + } + + + + +} diff --git a/client/src/main/java/client/utils/server/ServerUtils.java b/client/src/main/java/client/utils/server/ServerUtils.java index 5330fdd..b82d478 100644 --- a/client/src/main/java/client/utils/server/ServerUtils.java +++ b/client/src/main/java/client/utils/server/ServerUtils.java @@ -7,13 +7,11 @@ import com.google.inject.Inject; import commons.Ingredient; import commons.Recipe; import commons.RecipeIngredient; -import jakarta.ws.rs.ProcessingException; import jakarta.ws.rs.client.ClientBuilder; import org.glassfish.jersey.client.ClientConfig; import java.io.IOException; -import java.net.ConnectException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -171,10 +169,9 @@ public class ServerUtils { .target(this.endpoints.baseUrl()) // .request(APPLICATION_JSON) // .get(); - } catch (ProcessingException e) { - if (e.getCause() instanceof ConnectException) { - return false; - } + } + catch(Exception e){ + return false; //any exception caught will return false, not just processing exception. } return true; } diff --git a/locc.sh b/locc.sh new file mode 100644 index 0000000..93a0fea --- /dev/null +++ b/locc.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -u + +# 1. Check for git repo +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: current directory is not a git repository." >&2 + exit 1 +fi + +# 2. Define path patterns +PATH_CLIENT='client/src/**/*.java' +PATH_SERVER='server/src/**/*.java' +PATH_COMMONS='commons/src/**/*.java' +PATH_PROD='*/src/main/*.java' +PATH_TEST='*/src/test/*.java' +PATH_ALL='*.java' + +# 3. Helper functions + +# Standard count: Includes imports, respects WS changes +count_lines_standard() { + git show --format="" --patch "$1" -- "$2" \ + | grep -E '^\+[^+/][^/]+$' \ + | grep -v '+ *[*@]' \ + | wc -l +} + +# Strict count: Ignores imports, ignores pure WS changes +count_lines_strict() { + git show --format="" --patch -w "$1" -- "$2" \ + | grep -E '^\+[^+/][^/]+$' \ + | grep -v 'import' \ + | grep -v '+ *[*@]' \ + | wc -l +} + +echo "Analyzing commits on 'main'..." >&2 + +# 4. Main Loop +# Use %ae for Author Email +git log --no-merges --pretty=format:'%H %ae' main | { + + declare -A client_count + declare -A server_count + declare -A commons_count + declare -A prod_count + declare -A test_count + declare -A total_count + declare -A strict_count + declare -A seen_users + + while read -r hash raw_email; do + # Normalize email to lowercase + email=$(echo "$raw_email" | tr '[:upper:]' '[:lower:]') + seen_users["$email"]=1 + + # Run counts (Standard) + c_add=$(count_lines_standard "$hash" "$PATH_CLIENT") + s_add=$(count_lines_standard "$hash" "$PATH_SERVER") + k_add=$(count_lines_standard "$hash" "$PATH_COMMONS") + p_add=$(count_lines_standard "$hash" "$PATH_PROD") + t_add=$(count_lines_standard "$hash" "$PATH_TEST") + all_add=$(count_lines_standard "$hash" "$PATH_ALL") + + # Run count (Strict) + strict_add=$(count_lines_strict "$hash" "$PATH_ALL") + + # Accumulate + client_count["$email"]=$(( ${client_count["$email"]:-0} + c_add )) + server_count["$email"]=$(( ${server_count["$email"]:-0} + s_add )) + commons_count["$email"]=$(( ${commons_count["$email"]:-0} + k_add )) + prod_count["$email"]=$(( ${prod_count["$email"]:-0} + p_add )) + test_count["$email"]=$(( ${test_count["$email"]:-0} + t_add )) + total_count["$email"]=$(( ${total_count["$email"]:-0} + all_add )) + strict_count["$email"]=$(( ${strict_count["$email"]:-0} + strict_add )) + + printf "." >&2 + done + + echo "" >&2 + echo "Done." >&2 + + # 5. Print Table + # Widths: Email=40, Others=10, Strict=13 + printf "%-40s | %-10s | %-10s | %-10s | %-10s | %-10s | %-10s | %-13s\n" \ + "User Email" "Client" "Server" "Commons" "Prod" "Test" "Total" "Total (Strict)" + printf "%s\n" "-----------------------------------------|------------|------------|------------|------------|------------|------------|----------------" + + for email in "${!seen_users[@]}"; do + echo "$email" + done | sort | while read -r e; do + printf "%-40s | %-10d | %-10d | %-10d | %-10d | %-10d | %-10d | %-13d\n" \ + "$e" \ + "${client_count[$e]:-0}" \ + "${server_count[$e]:-0}" \ + "${commons_count[$e]:-0}" \ + "${prod_count[$e]:-0}" \ + "${test_count[$e]:-0}" \ + "${total_count[$e]:-0}" \ + "${strict_count[$e]:-0}" + done +} From 55b3811debe4f89b8c7f1e1eedfb151ce0df46cf Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 01:08:54 +0100 Subject: [PATCH 02/55] Sorts ingredients in alphabetic order --- .../java/client/scenes/recipe/IngredientListCtrl.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/main/java/client/scenes/recipe/IngredientListCtrl.java b/client/src/main/java/client/scenes/recipe/IngredientListCtrl.java index 7bb392f..679e05d 100644 --- a/client/src/main/java/client/scenes/recipe/IngredientListCtrl.java +++ b/client/src/main/java/client/scenes/recipe/IngredientListCtrl.java @@ -8,6 +8,7 @@ import com.google.inject.Inject; import commons.FormalIngredient; import commons.Recipe; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.function.Consumer; @@ -78,7 +79,13 @@ public class IngredientListCtrl implements LocaleAware { if (recipe == null) { this.ingredients = FXCollections.observableArrayList(new ArrayList<>()); } else { - List ingredientList = recipe.getIngredients(); + List ingredientList = recipe + .getIngredients() + .stream() + .sorted(Comparator.comparing(ingredient -> ingredient + .getIngredient() + .getName())) + .toList(); this.ingredients = FXCollections.observableArrayList(ingredientList); } From 4d17f103237dd4543245a8ba1631ef6315e1a5c5 Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 02:15:40 +0100 Subject: [PATCH 03/55] updated the port tests to the fixed findFreePort code --- .../src/test/java/server/PortCheckerTest.java | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/server/src/test/java/server/PortCheckerTest.java b/server/src/test/java/server/PortCheckerTest.java index 2843d99..a77e4d9 100644 --- a/server/src/test/java/server/PortCheckerTest.java +++ b/server/src/test/java/server/PortCheckerTest.java @@ -31,31 +31,37 @@ class PortCheckerTest { } } @Test - void invalidPort(){ - PortChecker checker = new PortChecker(); - - assertThrows(IllegalArgumentException.class, ()-> { - checker.isPortAvailable(-1); - } - ); - assertThrows(IllegalArgumentException.class, ()-> { - checker.isPortAvailable(65536); - } - ); - } - @Test - void findFreePort() throws IOException { + void findNotDefaultFreePort() throws IOException { PortChecker checker = new PortChecker(); int port = checker.findFreePort(); - int defaultPort = 8080; - int lastPort = 8090; + int lowestPossiblePort = 0; + int highestPossiblePort = 65535; - boolean greaterOrEqual = port >= defaultPort; - boolean lessOrEqual = port <= lastPort; - boolean inRange = greaterOrEqual && lessOrEqual; - boolean isItFree = checker.isPortAvailable(port); + assertTrue(port > lowestPossiblePort); + assertTrue(port <= highestPossiblePort); + assertTrue(checker.isPortAvailable(port)); + } + @Test + void findDefaultFreePort() throws IOException { + PortChecker checker = new PortChecker(); - assertTrue(inRange); + boolean free = checker.isPortAvailable(8080); + + assertTrue(free); + assertEquals(checker.findFreePort(),8080); + } + + @Test + void invalidFreePort(){ + PortChecker checker = new PortChecker(); + + assertThrows(IllegalArgumentException.class, () -> + checker.isPortAvailable(-1) + ); + + assertThrows(IllegalArgumentException.class, () -> + checker.isPortAvailable(65536) + ); } } \ No newline at end of file From 9222999505d961aaa3e721fc93256cb1bef89afa Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 02:16:22 +0100 Subject: [PATCH 04/55] made the tests more readable... --- .../java/server/api/RecipeControllerTest.java | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java index f4d8c77..8ea67d5 100644 --- a/server/src/test/java/server/api/RecipeControllerTest.java +++ b/server/src/test/java/server/api/RecipeControllerTest.java @@ -67,7 +67,9 @@ public class RecipeControllerTest { .mapToObj(x -> new Recipe( null, "Recipe " + x, - "en", List.of(), List.of())) + "en", + List.of(), + List.of())) .toList(); controller = new RecipeController( recipeService, @@ -80,8 +82,11 @@ public class RecipeControllerTest { if (tags.contains("test-from-init-data")) { ids = LongStream .range(0, NUM_RECIPES) - .map(idx -> recipeRepository.save(recipes.get((int) idx)).getId()) - .boxed().toList(); + .map(idx -> recipeRepository + .save(recipes.get((int) idx)) + .getId()) + .boxed() + .toList(); } // Some tests need to know the stored IDs of objects @@ -117,7 +122,9 @@ public class RecipeControllerTest { controller.getRecipes( Optional.empty(), Optional.empty(), - Optional.of(List.of("en", "nl"))).getBody().size());; + Optional.of(List.of("en", "nl"))) + .getBody() + .size()); } @Test @@ -129,7 +136,9 @@ public class RecipeControllerTest { controller.getRecipes( Optional.empty(), Optional.of(LIMIT), - Optional.of(List.of("en"))).getBody().size()); + Optional.of(List.of("en"))) + .getBody() + .size()); } @Test @@ -140,7 +149,9 @@ public class RecipeControllerTest { controller.getRecipes( Optional.empty(), Optional.empty(), - Optional.of(List.of("en", "nl"))).getBody().size()); + Optional.of(List.of("en", "nl"))) + .getBody() + .size()); } @Test @@ -151,7 +162,9 @@ public class RecipeControllerTest { controller.getRecipes( Optional.empty(), Optional.empty(), - Optional.of(List.of("nl"))).getBody().size()); + Optional.of(List.of("nl"))) + .getBody() + .size()); } @Test @Tag("test-from-init-data") @@ -161,7 +174,9 @@ public class RecipeControllerTest { controller.getRecipes( Optional.empty(), Optional.of(LIMIT), - Optional.of(List.of("en", "nl"))).getBody().size()); + Optional.of(List.of("en", "nl"))) + .getBody() + .size()); } @Test @@ -172,7 +187,8 @@ public class RecipeControllerTest { // The third item in the input list is the same as the third item retrieved from the database assertEquals( recipes.get(CHECK_INDEX), - controller.getRecipe(recipeIds.get(CHECK_INDEX)).getBody()); + controller.getRecipe(recipeIds.get(CHECK_INDEX)) + .getBody()); } @Test @@ -181,7 +197,8 @@ public class RecipeControllerTest { // There does not exist a recipe with ID=3 since there are no items in the repository. assertEquals( HttpStatus.NOT_FOUND, - controller.getRecipe((long) CHECK_INDEX).getStatusCode()); + controller.getRecipe((long) CHECK_INDEX) + .getStatusCode()); } @Test @@ -192,7 +209,8 @@ public class RecipeControllerTest { // The object has been successfully deleted assertEquals(HttpStatus.OK, - controller.deleteRecipe(recipeIds.get(DELETE_INDEX)).getStatusCode()); + controller.deleteRecipe(recipeIds.get(DELETE_INDEX)) + .getStatusCode()); } @Test @@ -211,7 +229,8 @@ public class RecipeControllerTest { public void deleteOneRecipeFail() { final Long DELETE_INDEX = 5L; assertEquals(HttpStatus.BAD_REQUEST, - controller.deleteRecipe(DELETE_INDEX).getStatusCode()); + controller.deleteRecipe(DELETE_INDEX) + .getStatusCode()); } @Test @@ -219,10 +238,16 @@ public class RecipeControllerTest { @Tag("need-ids") public void updateOneRecipeHasNewData() { final int UPDATE_INDEX = 5; - Recipe newRecipe = controller.getRecipe(recipeIds.get(UPDATE_INDEX)).getBody(); + Recipe newRecipe = controller.getRecipe(recipeIds + .get(UPDATE_INDEX)) + .getBody(); + newRecipe.setName("New recipe"); controller.updateRecipe(newRecipe.getId(), newRecipe); + assertEquals("New recipe", - recipeRepository.getReferenceById(recipeIds.get(UPDATE_INDEX)).getName()); + recipeRepository.getReferenceById(recipeIds + .get(UPDATE_INDEX)) + .getName()); } } From 0acbb13a49d04d7b7e08107859e67658047b8aa5 Mon Sep 17 00:00:00 2001 From: Aysegul Aydinlik Date: Thu, 22 Jan 2026 14:25:35 +0100 Subject: [PATCH 05/55] added IngredientControllerTest --- .../Ingredient/IngredientController.java | 2 +- .../Ingredient/IngredientControllerTest.java | 174 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 client/src/test/java/client/Ingredient/IngredientControllerTest.java diff --git a/client/src/main/java/client/Ingredient/IngredientController.java b/client/src/main/java/client/Ingredient/IngredientController.java index 429fecd..91cce05 100644 --- a/client/src/main/java/client/Ingredient/IngredientController.java +++ b/client/src/main/java/client/Ingredient/IngredientController.java @@ -23,7 +23,7 @@ public class IngredientController { private final RestTemplate restTemplate = new RestTemplate(); // Simplified REST client @FXML - private void handleDeleteIngredient(ActionEvent event) { + void handleDeleteIngredient(ActionEvent event) { // Get selected ingredient Ingredient selectedIngredient = ingredientListView.getSelectionModel().getSelectedItem(); if (selectedIngredient == null) { diff --git a/client/src/test/java/client/Ingredient/IngredientControllerTest.java b/client/src/test/java/client/Ingredient/IngredientControllerTest.java new file mode 100644 index 0000000..d8a4f79 --- /dev/null +++ b/client/src/test/java/client/Ingredient/IngredientControllerTest.java @@ -0,0 +1,174 @@ +package client.Ingredient; + +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import commons.Ingredient; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.event.ActionEvent; +import javafx.scene.control.Button; +import javafx.scene.control.ListView; +import javafx.stage.Stage; +import javafx.stage.Window; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@WireMockTest(httpPort = 8080) +class IngredientControllerMockTest { + + private IngredientController controller; + private ListView ingredientListView; + + // starting javaFX and allow use of listview and alert usage + @BeforeAll + static void initJavaFx() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + try { + Platform.startup(latch::countDown); + } catch (IllegalStateException alreadyStarted) { + latch.countDown(); + } + assertTrue(latch.await(3, TimeUnit.SECONDS), "JavaFX Platform failed to start"); + Platform.setImplicitExit(false); + } +//inject fxml fields and create controller + mock UI + @BeforeEach + void setup() throws Exception { + controller = new IngredientController(); + + ingredientListView = new ListView<>(); + ingredientListView.setItems(FXCollections.observableArrayList( + new Ingredient("Bread", 1, 2, 3), + new Ingredient("Cheese", 2, 2, 2), + new Ingredient("Ham", 3, 3, 3) + )); + + setPrivateField(controller, "ingredientListView", ingredientListView); + setPrivateField(controller, "deleteButton", new Button("Delete")); + } + + // pick ingredient -> backend says not in use -> fake delete ingredient + @Test + void deleteingredientwhennotusedcallsusagethendeleteandclearslist() throws Exception { + Ingredient selected = ingredientListView.getItems().get(0); + ingredientListView.getSelectionModel().select(selected); + + stubFor(get(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage")) + .willReturn(okJson("{\"ingredientId\":" + selected.getId() + ",\"usedInRecipes\":0}"))); + + stubFor(delete(urlEqualTo("/api/ingredients/" + selected.getId())) + .willReturn(ok())); + + // safe close for show and wait, run controller on JavaFX + try (DialogCloser closer = startDialogCloser()) { + runOnFxThreadAndWait(() -> controller.handleDeleteIngredient(new ActionEvent())); + } + + verify(getRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage"))); + verify(deleteRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId()))); + + assertEquals(0, ingredientListView.getItems().size()); + } + + //select ingredient -> if used backend says it and show warning -> safety delete but shouldn't happen + @Test + void deleteIngredientwhenUsedshowsWarningandDoesNotDeleteifDialogClosed() throws Exception { + Ingredient selected = ingredientListView.getItems().get(1); + ingredientListView.getSelectionModel().select(selected); + + stubFor(get(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage")) + .willReturn(okJson("{\"ingredientId\":" + selected.getId() + ",\"usedInRecipes\":2}"))); + + stubFor(delete(urlEqualTo("/api/ingredients/" + selected.getId())) + .willReturn(ok())); + + //safe close as if user selected cancel + try (DialogCloser closer = startDialogCloser()) { + runOnFxThreadAndWait(() -> controller.handleDeleteIngredient(new ActionEvent())); + } + + // check usage but not delete + verify(getRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage"))); + verify(0, deleteRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId()))); + + assertEquals(3, ingredientListView.getItems().size()); + } + + // fxml helper + private static void setPrivateField(Object target, String fieldName, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } + + //controller on JavaFX + private static void runOnFxThreadAndWait(Runnable action) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + try { + action.run(); + } finally { + latch.countDown(); + } + }); + assertTrue(latch.await(8, TimeUnit.SECONDS), "FX action timed out"); + } + + // safe close so that show and wait doesn't wait forever + private static DialogCloser startDialogCloser() { + AtomicBoolean running = new AtomicBoolean(true); + + Thread t = new Thread(() -> { + while (running.get()) { + try { + Thread.sleep(50); + } catch (InterruptedException ignored) { + } + + Platform.runLater(() -> { + for (Window w : Window.getWindows()) { + if (w instanceof Stage stage && stage.isShowing()) { + stage.close(); + } + } + }); + } + }, "javafx-dialog-closer"); + + t.setDaemon(true); + t.start(); + + return new DialogCloser(running); + } + + // dialog closer + private static final class DialogCloser implements AutoCloseable { + private final AtomicBoolean running; + + private DialogCloser(AtomicBoolean running) { + this.running = running; + } + + @Override + public void close() { + running.set(false); + } + } +} From f7a1deb9b1d94d63d55621b3387f237167817e05 Mon Sep 17 00:00:00 2001 From: Aysegul Aydinlik Date: Thu, 22 Jan 2026 14:31:49 +0100 Subject: [PATCH 06/55] added IngredientControllerTest + fix pipeline fail --- .../test/java/client/Ingredient/IngredientControllerTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/test/java/client/Ingredient/IngredientControllerTest.java b/client/src/test/java/client/Ingredient/IngredientControllerTest.java index d8a4f79..dd4da32 100644 --- a/client/src/test/java/client/Ingredient/IngredientControllerTest.java +++ b/client/src/test/java/client/Ingredient/IngredientControllerTest.java @@ -29,7 +29,9 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.verify; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +@EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".+") @WireMockTest(httpPort = 8080) class IngredientControllerMockTest { From 9603742220bbb52cead820fa4fb4a32c2d091f4c Mon Sep 17 00:00:00 2001 From: Aysegul Aydinlik Date: Thu, 22 Jan 2026 14:44:27 +0100 Subject: [PATCH 07/55] added IngredientControllerTest + fix comments --- .../src/main/java/client/Ingredient/IngredientController.java | 2 +- .../test/java/client/Ingredient/IngredientControllerTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/client/Ingredient/IngredientController.java b/client/src/main/java/client/Ingredient/IngredientController.java index 91cce05..e674ab6 100644 --- a/client/src/main/java/client/Ingredient/IngredientController.java +++ b/client/src/main/java/client/Ingredient/IngredientController.java @@ -23,7 +23,7 @@ public class IngredientController { private final RestTemplate restTemplate = new RestTemplate(); // Simplified REST client @FXML - void handleDeleteIngredient(ActionEvent event) { + public void handleDeleteIngredient(ActionEvent event) { // Get selected ingredient Ingredient selectedIngredient = ingredientListView.getSelectionModel().getSelectedItem(); if (selectedIngredient == null) { diff --git a/client/src/test/java/client/Ingredient/IngredientControllerTest.java b/client/src/test/java/client/Ingredient/IngredientControllerTest.java index dd4da32..a39613f 100644 --- a/client/src/test/java/client/Ingredient/IngredientControllerTest.java +++ b/client/src/test/java/client/Ingredient/IngredientControllerTest.java @@ -68,7 +68,7 @@ class IngredientControllerMockTest { // pick ingredient -> backend says not in use -> fake delete ingredient @Test - void deleteingredientwhennotusedcallsusagethendeleteandclearslist() throws Exception { + void DeleteIngredientWhenNotUsedCallsUsageThenDeleteAndClearsList() throws Exception { Ingredient selected = ingredientListView.getItems().get(0); ingredientListView.getSelectionModel().select(selected); @@ -91,7 +91,7 @@ class IngredientControllerMockTest { //select ingredient -> if used backend says it and show warning -> safety delete but shouldn't happen @Test - void deleteIngredientwhenUsedshowsWarningandDoesNotDeleteifDialogClosed() throws Exception { + void DeleteIngredientWhenUsedShowsWarningAndDoesNotDeleteIfDialogClosed() throws Exception { Ingredient selected = ingredientListView.getItems().get(1); ingredientListView.getSelectionModel().select(selected); From abe9750b9f404b72e67c9b0627ea8c4e77fbabc4 Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 14:45:20 +0100 Subject: [PATCH 08/55] Ingredients in the ingredient view are alphabetically ordered --- .../main/java/commons/FormalIngredient.java | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/commons/src/main/java/commons/FormalIngredient.java b/commons/src/main/java/commons/FormalIngredient.java index 88d713a..4fb1eed 100644 --- a/commons/src/main/java/commons/FormalIngredient.java +++ b/commons/src/main/java/commons/FormalIngredient.java @@ -55,8 +55,50 @@ public class FormalIngredient extends RecipeIngredient implements Scalable unit = Unit.fromString(unitSuffix); + if (unit.isEmpty() || !unit.get().isFormal() || unit.get().conversionFactor <= 0) { + return amount + unitSuffix; + } + + Unit currentUnit = unit.get(); + double baseAmount = amount * currentUnit.conversionFactor; + + if(currentUnit == Unit.GRAMME){ + if(baseAmount >= Unit.TONNE.conversionFactor){ + return baseAmount /Unit.TONNE.conversionFactor + Unit.TONNE.suffix; + } + if(baseAmount >=Unit.KILOGRAMME.conversionFactor) { + return baseAmount / Unit.KILOGRAMME.conversionFactor + Unit.KILOGRAMME.suffix; + } + } + + if (currentUnit == Unit.MILLILITRE && baseAmount >= Unit.LITRE.conversionFactor) { + return baseAmount /Unit.LITRE.conversionFactor + Unit.LITRE.suffix; + } + + + if(currentUnit == Unit.TABLESPOON){ + if(amount>=32){ + return amount/32 + Unit.POUND.suffix; + } + if(amount>=16){ + return amount /16 + Unit.CUP.suffix; + } + if(amount>=2){ + return amount /2 + Unit.OUNCE.suffix; + } + } + if (currentUnit == Unit.OUNCE && baseAmount >= 16) { + return amount / 16 + Unit.POUND.suffix; + } + + return amount + currentUnit.suffix; + } + public String toString() { - return amount + unitSuffix + " of " + ingredient.name; + return normalisedUnit()+ " of " + ingredient.name; } @Override From 8238e9b4624eec139e69862ebe6083faad8d3d7d Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 14:50:48 +0100 Subject: [PATCH 09/55] fix: magic numbers --- .../main/java/commons/FormalIngredient.java | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/commons/src/main/java/commons/FormalIngredient.java b/commons/src/main/java/commons/FormalIngredient.java index 4fb1eed..b977ab3 100644 --- a/commons/src/main/java/commons/FormalIngredient.java +++ b/commons/src/main/java/commons/FormalIngredient.java @@ -15,6 +15,11 @@ public class FormalIngredient extends RecipeIngredient implements Scalable=32){ - return amount/32 + Unit.POUND.suffix; + if(amount>=tbspToPoundConvert){ + return amount/tbspToPoundConvert + Unit.POUND.suffix; } - if(amount>=16){ - return amount /16 + Unit.CUP.suffix; + if(amount>=tbspToCupConvert){ + return amount /tbspToCupConvert + Unit.CUP.suffix; } - if(amount>=2){ - return amount /2 + Unit.OUNCE.suffix; + if(amount>=tbspToOunceConvert){ + return amount /tbspToOunceConvert + Unit.OUNCE.suffix; } } - if (currentUnit == Unit.OUNCE && baseAmount >= 16) { - return amount / 16 + Unit.POUND.suffix; + if (currentUnit == Unit.OUNCE && baseAmount >= OunceToPoundConvert) { + return amount / OunceToPoundConvert + Unit.POUND.suffix; } return amount + currentUnit.suffix; From 2dd8627f58007565c6ccaaf9226e9d4933de6af2 Mon Sep 17 00:00:00 2001 From: Aysegul Aydinlik Date: Thu, 22 Jan 2026 14:50:52 +0100 Subject: [PATCH 10/55] added IngredientControllerTest + fix pipeline --- .../test/java/client/Ingredient/IngredientControllerTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/test/java/client/Ingredient/IngredientControllerTest.java b/client/src/test/java/client/Ingredient/IngredientControllerTest.java index a39613f..65dd6af 100644 --- a/client/src/test/java/client/Ingredient/IngredientControllerTest.java +++ b/client/src/test/java/client/Ingredient/IngredientControllerTest.java @@ -68,7 +68,7 @@ class IngredientControllerMockTest { // pick ingredient -> backend says not in use -> fake delete ingredient @Test - void DeleteIngredientWhenNotUsedCallsUsageThenDeleteAndClearsList() throws Exception { + void deleteIngredientWhenNotUsedCallsUsageThenDeleteAndClearsList() throws Exception { Ingredient selected = ingredientListView.getItems().get(0); ingredientListView.getSelectionModel().select(selected); @@ -91,7 +91,7 @@ class IngredientControllerMockTest { //select ingredient -> if used backend says it and show warning -> safety delete but shouldn't happen @Test - void DeleteIngredientWhenUsedShowsWarningAndDoesNotDeleteIfDialogClosed() throws Exception { + void deleteIngredientWhenUsedShowsWarningAndDoesNotDeleteIfDialogClosed() throws Exception { Ingredient selected = ingredientListView.getItems().get(1); ingredientListView.getSelectionModel().select(selected); From b7bbebe222afb135a4476c63b0b9aa4f2ed478fa Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Sun, 18 Jan 2026 14:18:19 +0100 Subject: [PATCH 11/55] feat(client/scaling): initial implementation --- .../src/main/java/client/scenes/recipe/ScalableRecipeView.java | 2 -- commons/src/main/java/commons/Recipe.java | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java b/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java index aa80a97..1ebcc0d 100644 --- a/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java +++ b/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java @@ -41,7 +41,6 @@ public class ScalableRecipeView { public double getScaledKcal() { return scaledKcal.get(); } - public DoubleProperty scaleProperty() { return scale; } @@ -53,7 +52,6 @@ public class ScalableRecipeView { public ObjectProperty recipeProperty() { return recipe; } - public SimpleDoubleProperty scaledKcalProperty() { return scaledKcal; } diff --git a/commons/src/main/java/commons/Recipe.java b/commons/src/main/java/commons/Recipe.java index 3828fde..f69ca35 100644 --- a/commons/src/main/java/commons/Recipe.java +++ b/commons/src/main/java/commons/Recipe.java @@ -206,4 +206,4 @@ public class Recipe { this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum() * PER; } -} \ No newline at end of file +} From 23447a96d698d2119f46b36863f8251818fd6726 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Mon, 19 Jan 2026 14:38:21 +0100 Subject: [PATCH 12/55] feat(commons/recipe): introduce weight sum calculations --- commons/src/main/java/commons/Recipe.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/commons/src/main/java/commons/Recipe.java b/commons/src/main/java/commons/Recipe.java index f69ca35..e60fd30 100644 --- a/commons/src/main/java/commons/Recipe.java +++ b/commons/src/main/java/commons/Recipe.java @@ -203,7 +203,10 @@ public class Recipe { final double PER = 100; // Gram return this.ingredients.stream().mapToDouble(RecipeIngredient::getKcal).sum() / - this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum() * PER; + weight() * PER; + } + public double weight() { + return this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum(); } } From 8615187628005eed3138d387e18dfbff41510052 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Mon, 19 Jan 2026 14:39:01 +0100 Subject: [PATCH 13/55] feat(client/serving): declare UI infrastructure and data deps for servings --- .../scenes/recipe/ScalableRecipeView.java | 37 +++++++++---------- .../scenes/recipe/RecipeDetailView.fxml | 2 + 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java b/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java index 1ebcc0d..e9192ab 100644 --- a/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java +++ b/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java @@ -4,15 +4,19 @@ import commons.Recipe; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.beans.property.DoubleProperty; +import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; public class ScalableRecipeView { private final ObjectProperty recipe = new SimpleObjectProperty<>(); private final ObjectProperty scaled = new SimpleObjectProperty<>(); private final DoubleProperty scale = new SimpleDoubleProperty(); - private final SimpleDoubleProperty scaledKcal = new SimpleDoubleProperty(); + private final DoubleProperty scaledKcal = new SimpleDoubleProperty(); + private final IntegerProperty servings = new SimpleIntegerProperty(); + private final DoubleProperty servingSize = new SimpleDoubleProperty(); public ScalableRecipeView( Recipe recipe, Double scale @@ -24,10 +28,10 @@ public class ScalableRecipeView { this.recipe, this.scale); this.scaled.bind(binding); this.scaledKcal.bind(Bindings.createDoubleBinding(() -> this.scaled.get().kcal(), this.scaled)); - } - - public double getScale() { - return scale.get(); + this.servingSize.bind(Bindings.createDoubleBinding( + () -> this.scaled.get().weight() * ( 1.0 / this.servings.get()), + this.servings) + ); } public Recipe getRecipe() { @@ -38,21 +42,14 @@ public class ScalableRecipeView { return scaled.get(); } - public double getScaledKcal() { - return scaledKcal.get(); - } - public DoubleProperty scaleProperty() { - return scale; - } - - public ObjectProperty scaledProperty() { - return scaled; - } - - public ObjectProperty recipeProperty() { - return recipe; - } - public SimpleDoubleProperty scaledKcalProperty() { + public DoubleProperty scaledKcalProperty() { return scaledKcal; } + + public IntegerProperty servingsProperty() { + return servings; + } + public DoubleProperty servingSizeProperty() { + return servingSize; + } } diff --git a/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml b/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml index 0ebb01b..56f108a 100644 --- a/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml +++ b/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml @@ -27,6 +27,7 @@ diff --git a/client/src/main/resources/client/scenes/shopping/ShoppingList.fxml b/client/src/main/resources/client/scenes/shopping/ShoppingList.fxml index 314caf3..272e30d 100644 --- a/client/src/main/resources/client/scenes/shopping/ShoppingList.fxml +++ b/client/src/main/resources/client/scenes/shopping/ShoppingList.fxml @@ -4,12 +4,20 @@ - - - - - - - - + + + + + + + + + + + From 3ca526e0f1c7da28f8458169973ea0a91bbd2f56 Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 19:07:26 +0100 Subject: [PATCH 31/55] switched to switch case --- .../main/java/commons/FormalIngredient.java | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/commons/src/main/java/commons/FormalIngredient.java b/commons/src/main/java/commons/FormalIngredient.java index b977ab3..58f3c76 100644 --- a/commons/src/main/java/commons/FormalIngredient.java +++ b/commons/src/main/java/commons/FormalIngredient.java @@ -72,35 +72,39 @@ public class FormalIngredient extends RecipeIngredient implements Scalable= Unit.TONNE.conversionFactor){ - return baseAmount /Unit.TONNE.conversionFactor + Unit.TONNE.suffix; + switch (currentUnit){ + case GRAMME -> { + if(baseAmount >= Unit.TONNE.conversionFactor){ + return baseAmount /Unit.TONNE.conversionFactor + Unit.TONNE.suffix; + }if(baseAmount >=Unit.KILOGRAMME.conversionFactor) { + return baseAmount / Unit.KILOGRAMME.conversionFactor + Unit.KILOGRAMME.suffix; + } } - if(baseAmount >=Unit.KILOGRAMME.conversionFactor) { - return baseAmount / Unit.KILOGRAMME.conversionFactor + Unit.KILOGRAMME.suffix; + + case MILLILITRE -> { + if (baseAmount >= Unit.LITRE.conversionFactor) { + return baseAmount /Unit.LITRE.conversionFactor + Unit.LITRE.suffix; + } + } + + case TABLESPOON -> { + if(amount>=tbspToPoundConvert){ + return amount/tbspToPoundConvert + Unit.POUND.suffix; + } + if(amount>=tbspToCupConvert){ + return amount /tbspToCupConvert + Unit.CUP.suffix; + } + if(amount>=tbspToOunceConvert){ + return amount /tbspToOunceConvert + Unit.OUNCE.suffix; + } + } + + case OUNCE -> { + if (baseAmount >= OunceToPoundConvert) { + return amount / OunceToPoundConvert + Unit.POUND.suffix; + } } } - - if (currentUnit == Unit.MILLILITRE && baseAmount >= Unit.LITRE.conversionFactor) { - return baseAmount /Unit.LITRE.conversionFactor + Unit.LITRE.suffix; - } - - - if(currentUnit == Unit.TABLESPOON){ - if(amount>=tbspToPoundConvert){ - return amount/tbspToPoundConvert + Unit.POUND.suffix; - } - if(amount>=tbspToCupConvert){ - return amount /tbspToCupConvert + Unit.CUP.suffix; - } - if(amount>=tbspToOunceConvert){ - return amount /tbspToOunceConvert + Unit.OUNCE.suffix; - } - } - if (currentUnit == Unit.OUNCE && baseAmount >= OunceToPoundConvert) { - return amount / OunceToPoundConvert + Unit.POUND.suffix; - } - return amount + currentUnit.suffix; } From f03c12cc0f22bf4a5eec33cb17470e2901c4be37 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 19:52:53 +0100 Subject: [PATCH 32/55] feat(client/shopping): create add ingredient modal --- client/src/main/java/client/MyModule.java | 2 + .../ShoppingListNewItemPromptCtrl.java | 96 +++++++++++++++++++ .../shopping/ShoppingListItemAddModal.fxml | 14 +++ 3 files changed, 112 insertions(+) create mode 100644 client/src/main/java/client/scenes/shopping/ShoppingListNewItemPromptCtrl.java create mode 100644 client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml diff --git a/client/src/main/java/client/MyModule.java b/client/src/main/java/client/MyModule.java index cf55986..14eaa3e 100644 --- a/client/src/main/java/client/MyModule.java +++ b/client/src/main/java/client/MyModule.java @@ -22,6 +22,7 @@ import client.scenes.nutrition.NutritionViewCtrl; import client.scenes.recipe.IngredientListCtrl; import client.scenes.recipe.RecipeStepListCtrl; import client.scenes.shopping.ShoppingListCtrl; +import client.scenes.shopping.ShoppingListNewItemPromptCtrl; import client.service.ShoppingListService; import client.service.ShoppingListServiceImpl; import client.service.ShoppingListViewModel; @@ -61,6 +62,7 @@ public class MyModule implements Module { binder.bind(new TypeLiteral>() {}).toInstance( new WebSocketDataService<>() ); + binder.bind(ShoppingListNewItemPromptCtrl.class).in(Scopes.SINGLETON); binder.bind(ShoppingListCtrl.class).in(Scopes.SINGLETON); binder.bind(ShoppingListViewModel.class).toInstance(new ShoppingListViewModel()); binder.bind(ShoppingListService.class).to(ShoppingListServiceImpl.class); diff --git a/client/src/main/java/client/scenes/shopping/ShoppingListNewItemPromptCtrl.java b/client/src/main/java/client/scenes/shopping/ShoppingListNewItemPromptCtrl.java new file mode 100644 index 0000000..09bd44f --- /dev/null +++ b/client/src/main/java/client/scenes/shopping/ShoppingListNewItemPromptCtrl.java @@ -0,0 +1,96 @@ +package client.scenes.shopping; + +import client.utils.LocaleAware; +import client.utils.LocaleManager; +import client.utils.server.ServerUtils; +import com.google.inject.Inject; +import commons.FormalIngredient; +import commons.Ingredient; +import commons.Unit; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.event.ActionEvent; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.stage.Stage; + +import java.io.IOException; +import java.util.Arrays; +import java.util.function.Consumer; +import java.util.function.Function; + +public class ShoppingListNewItemPromptCtrl implements LocaleAware { + public MenuButton ingredientSelection; + private final ObjectProperty selected = new SimpleObjectProperty<>(); + private final ObjectProperty selectedUnit = new SimpleObjectProperty<>(); + private final ServerUtils server; + private final LocaleManager localeManager; + private Consumer newValueConsumer; + public MenuButton unitSelect; + public Spinner amountSelect; + + @Inject + public ShoppingListNewItemPromptCtrl(ServerUtils server, LocaleManager localeManager) { + this.server = server; + this.localeManager = localeManager; + } + + public void setNewValueConsumer(Consumer consumer) { + this.newValueConsumer = consumer; + } + + public void confirmAdd(ActionEvent actionEvent) { + if (selected.get() == null || selectedUnit.get() == null) { + System.err.println("You must select both an ingredient and an unit"); + return; + } + FormalIngredient fi = new FormalIngredient(selected.get(), amountSelect.getValue(), selectedUnit.get().suffix); + newValueConsumer.accept(fi); + Stage stage = (Stage) ingredientSelection.getScene().getWindow(); + stage.close(); + } + + public void cancelAdd(ActionEvent actionEvent) { + } + private void makeMenuItems( + MenuButton menu, + Iterable items, + Function labelMapper, + Consumer onSelect) { + // Iterates over the list of items and applies the label and onSelect handlers. + for (T item : items) { + MenuItem mi = new MenuItem(); + mi.setText(labelMapper.apply(item)); + mi.setOnAction(_ -> { + menu.setText(labelMapper.apply(item)); + onSelect.accept(item); + }); + menu.getItems().add(mi); + } + } + @Override + public void updateText() { + + } + @Override + public void initializeComponents() { + try { + amountSelect.setValueFactory( + new SpinnerValueFactory.DoubleSpinnerValueFactory(0, Double.MAX_VALUE, 0)); + amountSelect.setEditable(true); + makeMenuItems(ingredientSelection, server.getIngredients(), Ingredient::getName, selected::set); + makeMenuItems(unitSelect, + Arrays.stream(Unit.values()).filter(u -> u.formal).toList(), + Unit::toString, selectedUnit::set); + } catch (IOException | InterruptedException e) { + System.err.println(e.getMessage()); + } + } + + @Override + public LocaleManager getLocaleManager() { + return localeManager; + } +} diff --git a/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml b/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml new file mode 100644 index 0000000..31fbc18 --- /dev/null +++ b/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml @@ -0,0 +1,14 @@ + + + + + + + + + + + From 795926298eb5fabcebcf1f1d84b6cf221a968bff Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 19:53:27 +0100 Subject: [PATCH 33/55] feat(client/shopping): create custom editable shopping list cell element --- .../scenes/shopping/ShoppingListCell.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 client/src/main/java/client/scenes/shopping/ShoppingListCell.java diff --git a/client/src/main/java/client/scenes/shopping/ShoppingListCell.java b/client/src/main/java/client/scenes/shopping/ShoppingListCell.java new file mode 100644 index 0000000..d684435 --- /dev/null +++ b/client/src/main/java/client/scenes/shopping/ShoppingListCell.java @@ -0,0 +1,67 @@ +package client.scenes.shopping; + +import client.scenes.recipe.OrderedEditableListCell; +import commons.FormalIngredient; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.util.Pair; + +import java.util.Optional; + +public class ShoppingListCell extends OrderedEditableListCell>> { + private Node makeEditor() { + HBox editor = new HBox(); + Spinner amountInput = new Spinner<>(); + FormalIngredient ingredient = getItem().getKey(); + amountInput.setEditable(true); + amountInput.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0, Double.MAX_VALUE, ingredient.getAmount())); + Label textLabel = new Label(getItem().getKey().getUnitSuffix() + " of " + getItem().getKey().getIngredient().getName()); + editor.getChildren().addAll(amountInput, textLabel); + editor.addEventHandler(KeyEvent.KEY_RELEASED, e -> { + if (e.getCode() != KeyCode.ENTER) { + return; + } + Pair> pair = getItem(); + pair.getKey().setAmount(amountInput.getValue()); + commitEdit(pair); + }); + return editor; + } + @Override + public void startEdit() { + super.startEdit(); + this.setText(""); + this.setGraphic(makeEditor()); + } + @Override + protected void updateItem(Pair> item, boolean empty) { + super.updateItem(item, empty); + + if (empty) { + this.setText(""); + return; + } + + String display = item.getKey().toString() + + item.getValue().map(recipe -> { + return " (" + recipe + ")"; + }).orElse(""); + + this.setText(display); + } + @Override + public void cancelEdit() { + super.cancelEdit(); + } + + @Override + public void commitEdit(Pair> newValue) { + super.commitEdit(newValue); + } + +} From 670de432c5d177e4f39ec3c0538bd908e2a614ca Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 19:54:33 +0100 Subject: [PATCH 34/55] feat(client/shopping): delegate list view cell rendering and make add element handler --- .../scenes/shopping/ShoppingListCtrl.java | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java b/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java index 0e76b79..ec08c62 100644 --- a/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java +++ b/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java @@ -1,5 +1,6 @@ package client.scenes.shopping; +import client.UI; import client.service.ShoppingListService; import client.utils.LocaleAware; import client.utils.LocaleManager; @@ -7,8 +8,12 @@ import com.google.inject.Inject; import commons.FormalIngredient; import javafx.event.ActionEvent; import javafx.fxml.FXML; -import javafx.scene.control.ListCell; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; import javafx.scene.control.ListView; +import javafx.stage.Modality; +import javafx.stage.Stage; import javafx.util.Pair; import java.util.Optional; @@ -40,33 +45,37 @@ public class ShoppingListCtrl implements LocaleAware { } public void initializeComponents() { - this.shoppingListView.setCellFactory(l -> new ListCell<>() { - @Override - protected void updateItem(Pair> item, boolean empty) { - super.updateItem(item, empty); - - if (empty) { - this.setText(""); - return; - } - - String display = item.getKey().toString() + - item.getValue().map(recipe -> { - return " (" + recipe + ")"; - }).orElse(""); - - this.setText(display); - } - }); - + this.shoppingListView.setEditable(true); + this.shoppingListView.setCellFactory(l -> new ShoppingListCell()); + this.shoppingListView.getItems().setAll( + this.shopping.getItems() + ); + } + private void refreshList() { this.shoppingListView.getItems().setAll( this.shopping.getItems() ); } public void handleAddItem(ActionEvent actionEvent) { + Stage stage = new Stage(); + Pair root = UI.getFXML().load(ShoppingListNewItemPromptCtrl.class, + "client", "scenes", "shopping", "ShoppingListItemAddModal.fxml"); + root.getKey().setNewValueConsumer(fi -> { + this.shopping.putIngredient(fi); + refreshList(); + }); + stage.setScene(new Scene(root.getValue())); + stage.setTitle("My modal window"); + stage.initModality(Modality.WINDOW_MODAL); + stage.initOwner( + ((Node)actionEvent.getSource()).getScene().getWindow() ); + stage.show(); } public void handleRemoveItem(ActionEvent actionEvent) { + var x = this.shoppingListView.getSelectionModel().getSelectedItem(); + this.shopping.getItems().remove(x); + refreshList(); } } From 234c8c3d64b1ff32ea9cedbaccb61e41597c03ed Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 19:54:49 +0100 Subject: [PATCH 35/55] feat(client/shopping): fxml definition for various UI elements --- .../shopping/ShoppingListItemAddModal.fxml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml b/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml index 31fbc18..aee4c20 100644 --- a/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml +++ b/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml @@ -8,7 +8,17 @@ - + fx:controller="client.scenes.shopping.ShoppingListNewItemPromptCtrl" + prefHeight="400.0" prefWidth="600.0"> + + + + Unit... + Your ingredient... + + + + + + From 91997ae251a8e443aba68c0806ce1f75ac658419 Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 20:18:06 +0100 Subject: [PATCH 36/55] 2 decimal limit --- .../main/java/commons/FormalIngredient.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/commons/src/main/java/commons/FormalIngredient.java b/commons/src/main/java/commons/FormalIngredient.java index 58f3c76..ec7a382 100644 --- a/commons/src/main/java/commons/FormalIngredient.java +++ b/commons/src/main/java/commons/FormalIngredient.java @@ -2,6 +2,7 @@ package commons; import jakarta.persistence.Entity; +import java.text.DecimalFormat; import java.util.Objects; import java.util.Optional; @@ -19,6 +20,8 @@ public class FormalIngredient extends RecipeIngredient implements Scalable unit = Unit.fromString(unitSuffix); if (unit.isEmpty() || !unit.get().isFormal() || unit.get().conversionFactor <= 0) { - return amount + unitSuffix; + return numberFormat.format(amount) + unitSuffix; } Unit currentUnit = unit.get(); @@ -75,37 +78,37 @@ public class FormalIngredient extends RecipeIngredient implements Scalable { if(baseAmount >= Unit.TONNE.conversionFactor){ - return baseAmount /Unit.TONNE.conversionFactor + Unit.TONNE.suffix; + return numberFormat.format(baseAmount /Unit.TONNE.conversionFactor) + Unit.TONNE.suffix; }if(baseAmount >=Unit.KILOGRAMME.conversionFactor) { - return baseAmount / Unit.KILOGRAMME.conversionFactor + Unit.KILOGRAMME.suffix; + return numberFormat.format(baseAmount / Unit.KILOGRAMME.conversionFactor) + Unit.KILOGRAMME.suffix; } } case MILLILITRE -> { if (baseAmount >= Unit.LITRE.conversionFactor) { - return baseAmount /Unit.LITRE.conversionFactor + Unit.LITRE.suffix; + return numberFormat.format(baseAmount /Unit.LITRE.conversionFactor) + Unit.LITRE.suffix; } } case TABLESPOON -> { if(amount>=tbspToPoundConvert){ - return amount/tbspToPoundConvert + Unit.POUND.suffix; + return numberFormat.format(amount/tbspToPoundConvert) + Unit.POUND.suffix; } if(amount>=tbspToCupConvert){ - return amount /tbspToCupConvert + Unit.CUP.suffix; + return numberFormat.format(amount /tbspToCupConvert) + Unit.CUP.suffix; } if(amount>=tbspToOunceConvert){ - return amount /tbspToOunceConvert + Unit.OUNCE.suffix; + return numberFormat.format(amount /tbspToOunceConvert) + Unit.OUNCE.suffix; } } case OUNCE -> { if (baseAmount >= OunceToPoundConvert) { - return amount / OunceToPoundConvert + Unit.POUND.suffix; + return numberFormat.format(amount / OunceToPoundConvert) + Unit.POUND.suffix; } } } - return amount + currentUnit.suffix; + return numberFormat.format(amount) + currentUnit.suffix; } public String toString() { From e3e176939c874e416e93130524be13830b1697c7 Mon Sep 17 00:00:00 2001 From: Mei Chang van der Werff Date: Thu, 22 Jan 2026 20:26:52 +0100 Subject: [PATCH 37/55] fix: test fix after changes --- .../java/commons/FormalIngredientTest.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/commons/src/test/java/commons/FormalIngredientTest.java b/commons/src/test/java/commons/FormalIngredientTest.java index deeeb46..94edcd1 100644 --- a/commons/src/test/java/commons/FormalIngredientTest.java +++ b/commons/src/test/java/commons/FormalIngredientTest.java @@ -13,8 +13,8 @@ class FormalIngredientTest { FormalIngredient toTonne = new FormalIngredient(ingredient,1_000_000,"g"); - assertEquals(toKg.normalisedUnit(),"1.0kg"); - assertEquals(toTonne.normalisedUnit(),"1.0t"); + assertEquals(toKg.normalisedUnit(),"1.00kg"); + assertEquals(toTonne.normalisedUnit(),"1.00t"); } @Test @@ -22,7 +22,7 @@ class FormalIngredientTest { Ingredient ingredient = new Ingredient("Bread", 1, 2, 3); FormalIngredient toKg = new FormalIngredient(ingredient,1_000,"ml"); - assertEquals(toKg.normalisedUnit(),"1.0l"); + assertEquals(toKg.normalisedUnit(),"1.00l"); } @Test @@ -30,7 +30,7 @@ class FormalIngredientTest { Ingredient ingredient = new Ingredient("Bread", 1, 2, 3); FormalIngredient toPound = new FormalIngredient(ingredient,16,"oz"); - assertEquals(toPound.normalisedUnit(),"1.0lb"); + assertEquals(toPound.normalisedUnit(),"1.00lb"); } @Test @@ -40,9 +40,9 @@ class FormalIngredientTest { FormalIngredient toPound = new FormalIngredient(ingredient,32,"tbsp"); FormalIngredient toOunce = new FormalIngredient(ingredient,2,"tbsp"); - assertEquals(toCup.normalisedUnit(),"1.0cup(s)"); - assertEquals(toPound.normalisedUnit(),"1.0lb"); - assertEquals(toOunce.normalisedUnit(),"1.0oz"); + assertEquals(toCup.normalisedUnit(),"1.00cup(s)"); + assertEquals(toPound.normalisedUnit(),"1.00lb"); + assertEquals(toOunce.normalisedUnit(),"1.00oz"); } @@ -52,7 +52,7 @@ class FormalIngredientTest { FormalIngredient informal = new FormalIngredient(ingredient,10,""); FormalIngredient toSmall = new FormalIngredient(ingredient,10,"g"); - assertEquals(informal.normalisedUnit(),"10.0"); - assertEquals(toSmall.normalisedUnit(),"10.0g"); + assertEquals(informal.normalisedUnit(),"10.00"); + assertEquals(toSmall.normalisedUnit(),"10.00g"); } } \ No newline at end of file From d23eb34a00b432ee0a72deb58cc98d435b6c7287 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 20:51:10 +0100 Subject: [PATCH 38/55] fix: into initializeComponents --- .../java/client/scenes/Ingredient/IngredientListCtrl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java b/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java index 38e6077..c06a04a 100644 --- a/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java +++ b/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java @@ -67,8 +67,8 @@ public class IngredientListCtrl implements LocaleAware { return this.localeManager; } - @FXML - public void initialize() { + @Override + public void initializeComponents() { ingredientListView.setCellFactory(list -> new ListCell<>() { @Override protected void updateItem(Ingredient item, boolean empty) { From 7cddc0303c28242a29dd6932aaec55e3adbfe12a Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 21:04:28 +0100 Subject: [PATCH 39/55] fix: Search bar immediately clears search params and returns server contents after ESCAPE key-press --- client/src/main/java/client/scenes/SearchBarCtrl.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/src/main/java/client/scenes/SearchBarCtrl.java b/client/src/main/java/client/scenes/SearchBarCtrl.java index 065455c..8b87a42 100644 --- a/client/src/main/java/client/scenes/SearchBarCtrl.java +++ b/client/src/main/java/client/scenes/SearchBarCtrl.java @@ -10,6 +10,7 @@ import javafx.animation.PauseTransition; import javafx.concurrent.Task; import javafx.fxml.FXML; import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; import javafx.util.Duration; import java.io.IOException; @@ -146,6 +147,11 @@ public class SearchBarCtrl implements LocaleAware { }); this.searchField.setOnKeyReleased(event -> { + if (event.getCode() == KeyCode.ESCAPE) { + searchField.clear(); + this.onSearch(); + return; + } // This cancels the current debounce timer and restarts it. this.searchDebounce.playFromStart(); }); From be1b4b9ee313d6a9813de3a7afc2b8de226d75bb Mon Sep 17 00:00:00 2001 From: Aysegul Aydinlik Date: Thu, 22 Jan 2026 21:50:13 +0100 Subject: [PATCH 40/55] added a feature and meaningful addition --- .../java/client/scenes/SearchBarCtrl.java | 57 ++++++++++++++++++- .../java/client/utils/server/Endpoints.java | 16 ++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/client/src/main/java/client/scenes/SearchBarCtrl.java b/client/src/main/java/client/scenes/SearchBarCtrl.java index 065455c..36a2740 100644 --- a/client/src/main/java/client/scenes/SearchBarCtrl.java +++ b/client/src/main/java/client/scenes/SearchBarCtrl.java @@ -13,8 +13,11 @@ import javafx.scene.control.TextField; import javafx.util.Duration; import java.io.IOException; +import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.function.Consumer; +import java.util.stream.Collectors; /** * Controller for the search bar component. @@ -99,7 +102,11 @@ public class SearchBarCtrl implements LocaleAware { currentSearchTask = new Task<>() { @Override protected List call() throws IOException, InterruptedException { - return serverUtils.getRecipesFiltered(filter, configService.getConfig().getRecipeLanguages()); + var recipes = serverUtils.getRecipesFiltered( + "", + configService.getConfig().getRecipeLanguages() + ); + return applyMultiTermAndFilter(recipes, filter); } }; @@ -150,4 +157,52 @@ public class SearchBarCtrl implements LocaleAware { this.searchDebounce.playFromStart(); }); } + private List applyMultiTermAndFilter(List recipes, String query) { + if (recipes == null) { + return List.of(); + } + if (query == null || query.isBlank()) { + return recipes; + } + + var tokens = Arrays.stream(query.toLowerCase(Locale.ROOT).split("[\\s,]+")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .filter(s -> !s.equals("and")) + .toList(); + + if (tokens.isEmpty()) { + return recipes; + } + + return recipes.stream() + .filter(r -> { + var sb = new StringBuilder(); + + if (r.getName() != null) { + sb.append(r.getName()).append(' '); + } + + if (r.getIngredients() != null) { + r.getIngredients().forEach(i -> { + if (i != null) { + sb.append(i).append(' '); + } + }); + } + + if (r.getPreparationSteps() != null) { + r.getPreparationSteps().forEach(s -> { + if (s != null) { + sb.append(s).append(' '); + } + }); + } + + var haystack = sb.toString().toLowerCase(Locale.ROOT); + return tokens.stream().allMatch(haystack::contains); + }) + .collect(Collectors.toList()); + } + } diff --git a/client/src/main/java/client/utils/server/Endpoints.java b/client/src/main/java/client/utils/server/Endpoints.java index 5b31304..6c23870 100644 --- a/client/src/main/java/client/utils/server/Endpoints.java +++ b/client/src/main/java/client/utils/server/Endpoints.java @@ -4,7 +4,9 @@ import client.utils.ConfigService; import com.google.inject.Inject; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; import java.util.List; public class Endpoints { @@ -81,9 +83,23 @@ public class Endpoints { } public HttpRequest.Builder getRecipesWith(String params) { + if (params != null && params.contains("search=")) { + int start = params.indexOf("search=") + "search=".length(); + int end = params.indexOf('&', start); + if (end == -1) { + end = params.length(); + } + + String rawValue = params.substring(start, end); + String encodedValue = URLEncoder.encode(rawValue, StandardCharsets.UTF_8); + + params = params.substring(0, start) + encodedValue + params.substring(end); + } + return this.http(this.createApiUrl("/recipes?" + params)).GET(); } + public HttpRequest.Builder createIngredient(HttpRequest.BodyPublisher body) { String url = this.createApiUrl("/ingredients"); From 4d57f84ccba6d769c93852830b68fd50213078ee Mon Sep 17 00:00:00 2001 From: Rithvik Sriram Date: Thu, 22 Jan 2026 22:37:49 +0100 Subject: [PATCH 41/55] Added a few extra tests in RecipeControllerTest --- .../java/server/api/RecipeControllerTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java index 8ea67d5..9e397c7 100644 --- a/server/src/test/java/server/api/RecipeControllerTest.java +++ b/server/src/test/java/server/api/RecipeControllerTest.java @@ -18,6 +18,7 @@ import server.service.RecipeService; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.LongStream; @@ -127,6 +128,60 @@ public class RecipeControllerTest { .size()); } + @Test + @Tag("test-from-init-data") + public void getRecipesWithNegativeLimit(){ + assertEquals(0, + controller.getRecipes(Optional.empty(), + Optional.of(-2), + Optional.of(List.of("en"))) + .getBody().size()); + + } + @Test + @Tag("test-from-init-data") + public void getRecipesWithSearch() { + + recipeRepository.save(new Recipe( + null, + "banana pie", + "en", + List.of(), + List.of())); + + assertEquals(1, controller.getRecipes( + Optional.of("banana"), + Optional.empty(), + Optional.of(List.of("en"))).getBody().size()); + + assertEquals("banana pie", Objects.requireNonNull(controller.getRecipes( + Optional.of("banana"), + Optional.empty(), + Optional.of(List.of("en"))).getBody()).getFirst().getName()); + } + @Test + @Tag("test-from-init-data") + public void getRecipesWithZeroLimit() { + assertEquals(0, controller.getRecipes( + Optional.empty(), + Optional.of(0), + Optional.of(List.of("en"))).getBody().size()); + } + @Test + @Tag("test-from-init-data") + public void getRecipesWithEmptySearch() { + var response = controller.getRecipes( + Optional.of(" "), + Optional.empty(), + Optional.of(List.of("en")) + ); + assertEquals(recipes.size(), response.getBody().size()); + } + + + + + @Test @Tag("test-from-init-data") public void getSomeRecipes() { From 1542117ac61b080f8fd7255cc0d8cbd786c7cd3d Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 22:55:56 +0100 Subject: [PATCH 42/55] fix: refresh the favourites list if any change occurs to the original list --- .../client/scenes/FoodpalApplicationCtrl.java | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java index d553712..7ba5d3d 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -32,6 +32,10 @@ import commons.ws.messages.Message; import commons.ws.messages.UpdateRecipeMessage; import jakarta.inject.Inject; import javafx.application.Platform; +import javafx.beans.property.ListProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; import javafx.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.Button; @@ -66,8 +70,7 @@ public class FoodpalApplicationCtrl implements LocaleAware { @FXML public ListView recipeList; - @FXML - private ListView ingredientListView; + private final ListProperty favouriteRecipeList = new SimpleListProperty<>(); @FXML private Button addRecipeButton; @@ -88,8 +91,6 @@ public class FoodpalApplicationCtrl implements LocaleAware { @FXML private Button manageIngredientsButton; - private List allRecipes = new ArrayList<>(); - @FXML private Label updatedBadge; @@ -287,6 +288,13 @@ public class FoodpalApplicationCtrl implements LocaleAware { openSelectedRecipe(); } }); + recipeList.getItems().addListener((ListChangeListener.Change c) -> { + favouriteRecipeList.set( + FXCollections.observableList( + recipeList.getItems().stream().filter(r -> config.isFavourite(r.getId())).toList() + )); + System.out.println(favouriteRecipeList); + }); this.initializeSearchBar(); refresh(); @@ -325,8 +333,7 @@ public class FoodpalApplicationCtrl implements LocaleAware { logger.severe(msg); printError(msg); } - - allRecipes = new ArrayList<>(recipes); + recipeList.getItems().setAll(recipes); applyRecipeFilterAndKeepSelection(); showUpdatedBadge(); @@ -422,16 +429,20 @@ public class FoodpalApplicationCtrl implements LocaleAware { public void applyRecipeFilterAndKeepSelection() { Recipe selected = recipeList.getSelectionModel().getSelectedItem(); Long selectedId = selected == null ? null : selected.getId(); - - List view = allRecipes; + List view = recipeList.getItems().stream().toList(); if (favouritesOnlyToggle != null && favouritesOnlyToggle.isSelected()) { - view = allRecipes.stream() - .filter(r -> config.isFavourite(r.getId())) - .collect(Collectors.toList()); + view = favouriteRecipeList.get(); + recipeList.getItems().setAll(view); + } + try { + if (favouritesOnlyToggle != null && !favouritesOnlyToggle.isSelected()) { + recipeList.getItems().setAll(server.getRecipes(config.getRecipeLanguages())); + } + } + catch (IOException | InterruptedException e) { + logger.severe(e.getMessage()); } - - recipeList.getItems().setAll(view); // restore selection if possible if (selectedId != null) { From f281ce1333d873161cdb9ede1ffa9d9f93b37635 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 22:56:07 +0100 Subject: [PATCH 43/55] chore: cleanup unused code in foodpal --- .../client/scenes/FoodpalApplicationCtrl.java | 117 ------------------ 1 file changed, 117 deletions(-) diff --git a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java index 7ba5d3d..b6377d7 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -2,12 +2,10 @@ package client.scenes; import java.io.IOException; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.logging.Logger; -import java.util.stream.Collectors; import client.exception.InvalidModificationException; import client.scenes.nutrition.NutritionViewCtrl; @@ -21,7 +19,6 @@ import client.utils.LocaleManager; import client.utils.server.ServerUtils; import client.utils.WebSocketDataService; import client.utils.WebSocketUtils; -import commons.Ingredient; import commons.Recipe; import commons.ws.Topics; @@ -42,7 +39,6 @@ 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 javafx.scene.paint.Color; import org.apache.commons.lang3.NotImplementedException; @@ -463,119 +459,6 @@ 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(recipeName ->{ - String trim = recipeName.trim(); - if(trim.isEmpty()){ - showError("Invalid Input", "Cannot have empty ingredient name"); - return; - } - if(ingredientListView.getItems().stream() - .map(Ingredient::getName) - .anyMatch(ingName -> ingName.equalsIgnoreCase(trim))){ - showError("Duplicate Ingredient", "Cannot have duplicate ingredients," + - " Please provide unique Ingredient names"); - } - }); - } - - // 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 { From a3c08170d389ed76a6851e24ab9cb8e5937e1c62 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Sat, 17 Jan 2026 14:21:35 +0100 Subject: [PATCH 44/55] chore(lang): zh_TW --- .../client/scenes/LangSelectMenuCtrl.java | 2 +- client/src/main/resources/flag_zht.png | Bin 0 -> 1101 bytes .../src/main/resources/locale/lang.properties | 3 +- .../main/resources/locale/lang_en.properties | 3 +- .../main/resources/locale/lang_nl.properties | 3 +- .../main/resources/locale/lang_pl.properties | 3 +- .../main/resources/locale/lang_tok.properties | 3 +- .../main/resources/locale/lang_zht.properties | 35 ++++++++++++++++++ 8 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 client/src/main/resources/flag_zht.png create mode 100644 client/src/main/resources/locale/lang_zht.properties diff --git a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java index 0dc719a..af56631 100644 --- a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java +++ b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java @@ -46,7 +46,7 @@ public class LangSelectMenuCtrl implements LocaleAware { @Override public void initializeComponents() { - langSelectMenu.getItems().setAll("en", "pl", "nl", "tok", "tr"); + langSelectMenu.getItems().setAll("en", "pl", "nl", "zht", "tok", "tr"); langSelectMenu.setValue(manager.getLocale().getLanguage()); langSelectMenu.setConverter(new StringConverter() { @Override diff --git a/client/src/main/resources/flag_zht.png b/client/src/main/resources/flag_zht.png new file mode 100644 index 0000000000000000000000000000000000000000..5c5b076b7b70071eeea143fbc117c6d73a11e7fa GIT binary patch literal 1101 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=oCO|{#S9GG!XV7ZFl&wk0|WDa zPZ!6KiaBp*`Flr4O0>^E@?W`g!tEoGE&<|wAYo$u>0Q_ai)9GmN(`S zi77|Uo#>E#?5OXWsVMe&()oIUiF-M=Fg$y9?@@`?k)-4Z9o?ASX|91$0aN~FcQ0*? z$l+NYoS64+?UG{OOq)F(Dn%mQ))q2iOpNRgKNxM#`^)E`x7}6Y!GVVx0(9O@@OW~h zCENG$hXVCZm%gIP{s0YMlU>Ko&c1JC{8_oVS&ZRQ=2VZA*4x|DKUD43oc=u{!{9;D z%?}m3Hbg~NetDs9W?tU1{PTaq5Ur_ulM35`+BK#mmVM)6W}dccuCe6Qix;Q+`0yAQ z8hW_98=p*J+-~i)uwYxM;~o(|HSQTczAlTikNe3bBpf*O=8f#4i>5xymNlQ;Z}Iub zeio^D_W$2aU%S?5?%ewY|Lg8|GUseFi`)NidiZ)X4wiooq1(@^elXtrT&g~<$JRFO z(8JW4eUsJeJI-Hsx7g$3-yd1+dQJP7%Okn6>fPHO%xyd9$X!-#-Fd{;%uMdXo16SS z{qbv0Z`ygTe*fpoFN%C8pU$1=w>oBj-P4d}pr>=b+3H<4@%FCNn##Rxk3-Mu)w6&6 z{p+Hny3qR_0cb9Lxd27ZjuZdnO zOG->07u{5uo82YWaQyPptKU}bI(sQ-o>Ss%r^NqJq1y|0$4~GP14c1K$<UJx&Qy4PwKYrOLwi} zz2Mif%2z||beL>NKfC;Y*T;pu`S)_-rnO7N|4sAYcZhP3a+Y-2&p+YW>i_I77(KW> q7PBkpD)h=VY-un7XCLwf Date: Sat, 17 Jan 2026 14:26:34 +0100 Subject: [PATCH 45/55] chore(lang): adds Chinese that looks just a liiiiiiittle bit different --- .../client/scenes/LangSelectMenuCtrl.java | 2 +- client/src/main/resources/flag_zhc.png | Bin 0 -> 1112 bytes .../src/main/resources/locale/lang.properties | 1 + .../main/resources/locale/lang_en.properties | 1 + .../main/resources/locale/lang_nl.properties | 1 + .../main/resources/locale/lang_pl.properties | 1 + .../main/resources/locale/lang_tr.properties | 9 +++-- .../main/resources/locale/lang_zhc.properties | 38 ++++++++++++++++++ .../main/resources/locale/lang_zht.properties | 5 ++- 9 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 client/src/main/resources/flag_zhc.png create mode 100644 client/src/main/resources/locale/lang_zhc.properties diff --git a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java index af56631..1885d6b 100644 --- a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java +++ b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java @@ -46,7 +46,7 @@ public class LangSelectMenuCtrl implements LocaleAware { @Override public void initializeComponents() { - langSelectMenu.getItems().setAll("en", "pl", "nl", "zht", "tok", "tr"); + langSelectMenu.getItems().setAll("en", "pl", "nl", "zht", "zhc", "tok", "tr"); langSelectMenu.setValue(manager.getLocale().getLanguage()); langSelectMenu.setConverter(new StringConverter() { @Override diff --git a/client/src/main/resources/flag_zhc.png b/client/src/main/resources/flag_zhc.png new file mode 100644 index 0000000000000000000000000000000000000000..e14c147a5757c701e991fc5de19b6752db353a24 GIT binary patch literal 1112 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=oCO|{#S9GG!XV7ZFl&wk0|N_} zr;B4q#hkY@{WD}jWscjwKYQlgnRjXBxzf5VTQUSfR&nSaUDUzGqAwQ}BET=Vgngmt z6tDMHUzUmRR|)D|;@Zn;$sxK*{9qgtuZWUIF4MBk**Rt9W&dpXy(gRc-ne`7%|DHE z&rMG6oB4C!`Pr5K-E{3@P5<;?yu`TVB#Q@oK;OYtDoNkP1ry|bH1-KB&^i3X-FMBV z7;_aRg{Z4j;} zIoUdS?y}o|EX^-e-rKBr?_Od-zG~ zvWTaN9E`V2D~=yt`}jzB?fmQ)Q+%C{z6!s$?*Bb~_3iD#T`T9ksVrT}Gu1eE)%zd(89`hHyXvY7zPz$ZTi(o) zb$|Do_|wTg@5^slPD+sV=?F6kGgY$}4z_(9y!#nnz|w;Od&8RF84LE;T{7cFHTkj2jCjTy;8^@!vp!(3`^SAHVnjgGdEd1m1w{@wPlEQ`g_S~2HW%c($z4{8C z%lGpVLV5qkltu2}WxGu~b;X{$&nA}FKAQFDT=mm^$NtWJvMkwaJ)6a4&emYX-q(B| zmo54>*Zq!!@BT~Qg(uW&9dp@Gv(e>k*YEjUQj1r_7Dn!iIOH;SQzpl|h%NciJI
sUx!|+dkqf(C3jE1TWU&+IsN4RLaefEZrkL6CEHdIhr@b~z) zT=_eqa{7bP0l+XkKZqy4E literal 0 HcmV?d00001 diff --git a/client/src/main/resources/locale/lang.properties b/client/src/main/resources/locale/lang.properties index 966b89e..59f8bb1 100644 --- a/client/src/main/resources/locale/lang.properties +++ b/client/src/main/resources/locale/lang.properties @@ -44,3 +44,4 @@ lang.pl.display=Polish lang.tok.display=toki pona lang.tr.display=T\u00FCrk\u00E7e lang.zht.display=中文(台灣) +lang.zhc.display=中文(中国大陆) diff --git a/client/src/main/resources/locale/lang_en.properties b/client/src/main/resources/locale/lang_en.properties index 2bd11ae..bf41079 100644 --- a/client/src/main/resources/locale/lang_en.properties +++ b/client/src/main/resources/locale/lang_en.properties @@ -46,3 +46,4 @@ lang.pl.display=Polish lang.tok.display=toki pona lang.tr.display=T\u00FCrk\u00E7e lang.zht.display=中文(台灣) +lang.zhc.display=中文(中国大陆) diff --git a/client/src/main/resources/locale/lang_nl.properties b/client/src/main/resources/locale/lang_nl.properties index 79295ee..f944251 100644 --- a/client/src/main/resources/locale/lang_nl.properties +++ b/client/src/main/resources/locale/lang_nl.properties @@ -45,3 +45,4 @@ lang.pl.display=Pools lang.tok.display=toki pona lang.tr.display=T\u00FCrk\u00E7e lang.zht.display=中文(台灣) +lang.zhc.display=中文(中国大陆) diff --git a/client/src/main/resources/locale/lang_pl.properties b/client/src/main/resources/locale/lang_pl.properties index de7c244..28695ad 100644 --- a/client/src/main/resources/locale/lang_pl.properties +++ b/client/src/main/resources/locale/lang_pl.properties @@ -46,3 +46,4 @@ lang.pl.display=Polski lang.tok.display=toki pona lang.tr.display=T\u00FCrk\u00E7e lang.zht.display=中文(台灣) +lang.zhc.display=中文(中国大陆) diff --git a/client/src/main/resources/locale/lang_tr.properties b/client/src/main/resources/locale/lang_tr.properties index 4fa8ada..4af12b8 100644 --- a/client/src/main/resources/locale/lang_tr.properties +++ b/client/src/main/resources/locale/lang_tr.properties @@ -40,7 +40,10 @@ menu.label.selected-langs=Diller menu.shopping.title=Al??veri? listesi -lang.en.display=\u0130ngilizce -lang.nl.display=Hollandaca -lang.pl.display=Leh\u00E7e +lang.en.display=English +lang.nl.display=Nederlands +lang.pl.display=Polski +lang.tok.display=toki pona lang.tr.display=T\u00FCrk\u00E7e +lang.zht.display=中文(台灣) +lang.zhc.display=中文(中国大陆) diff --git a/client/src/main/resources/locale/lang_zhc.properties b/client/src/main/resources/locale/lang_zhc.properties new file mode 100644 index 0000000..646ce54 --- /dev/null +++ b/client/src/main/resources/locale/lang_zhc.properties @@ -0,0 +1,38 @@ +add.ingredient.title=添加配料 +add.recipe.title=创建食谱 +add.step.title=添加步骤 + +add.ingredient.label=配料 +add.recipe.label=食谱名称 +add.step.label=步骤 + +button.ok=确认 +button.cancel=取消 + +menu.label.recipes=食谱 +menu.label.ingredients=配料 +menu.label.preparation=准备步骤 + +menu.button.add.recipe=创建食谱 +menu.button.add.ingredient=添加配料 +menu.button.add.step=添加步骤 + +menu.button.remove.recipe=清除食谱 +menu.button.remove.ingredient=清除配料 +menu.button.remove.step=清除步骤 + +menu.button.edit=编辑 +menu.button.clone=复制 +menu.button.print=打印食谱 + +menu.search=搜索 + +menu.label.selected-langs=语言 + +lang.en.display=English +lang.nl.display=Nederlands +lang.pl.display=Polski +lang.tok.display=toki pona +lang.tr.display=T\u00FCrk\u00E7e +lang.zht.display=中文(台灣) +lang.zhc.display=中文(中国大陆) diff --git a/client/src/main/resources/locale/lang_zht.properties b/client/src/main/resources/locale/lang_zht.properties index 6b604b9..fe5c3fb 100644 --- a/client/src/main/resources/locale/lang_zht.properties +++ b/client/src/main/resources/locale/lang_zht.properties @@ -32,4 +32,7 @@ menu.label.selected-langs=語言 lang.en.display=English lang.nl.display=Nederlands lang.pl.display=Polski -lang.zht.display=中文(台灣) \ No newline at end of file +lang.tok.display=toki pona +lang.tr.display=T\u00FCrk\u00E7e +lang.zht.display=中文(台灣) +lang.zhc.display=中文(中国大陆) From 827fe195a904c62858033ae87a2cbdc641d95fd7 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 22 Jan 2026 23:17:19 +0100 Subject: [PATCH 46/55] fix: static member of Config declaring all languages supported by the application --- client/src/main/java/client/scenes/LangSelectMenuCtrl.java | 3 ++- client/src/main/java/client/scenes/LanguageFilterCtrl.java | 3 ++- .../src/main/java/client/scenes/recipe/RecipeDetailCtrl.java | 2 +- client/src/main/java/client/utils/Config.java | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java index 1885d6b..76fdef8 100644 --- a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java +++ b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java @@ -1,5 +1,6 @@ package client.scenes; +import client.utils.Config; import client.utils.LocaleAware; import client.utils.LocaleManager; import com.google.inject.Inject; @@ -46,7 +47,7 @@ public class LangSelectMenuCtrl implements LocaleAware { @Override public void initializeComponents() { - langSelectMenu.getItems().setAll("en", "pl", "nl", "zht", "zhc", "tok", "tr"); + langSelectMenu.getItems().setAll(Config.languages); langSelectMenu.setValue(manager.getLocale().getLanguage()); langSelectMenu.setConverter(new StringConverter() { @Override diff --git a/client/src/main/java/client/scenes/LanguageFilterCtrl.java b/client/src/main/java/client/scenes/LanguageFilterCtrl.java index 80d01a0..fd1de08 100644 --- a/client/src/main/java/client/scenes/LanguageFilterCtrl.java +++ b/client/src/main/java/client/scenes/LanguageFilterCtrl.java @@ -1,5 +1,6 @@ package client.scenes; +import client.utils.Config; import client.utils.ConfigService; import client.utils.LocaleAware; import client.utils.LocaleManager; @@ -54,7 +55,7 @@ public class LanguageFilterCtrl implements LocaleAware { public void initializeComponents() { var items = this.langFilterMenu.getItems(); - final List languages = List.of("en", "nl", "pl", "tok", "tr"); + final List languages = List.of(Config.languages); this.selectedLanguages = this.configService.getConfig().getRecipeLanguages(); this.updateMenuButtonDisplay(); diff --git a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java index 04140d1..d923ffd 100644 --- a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java +++ b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java @@ -422,7 +422,7 @@ public class RecipeDetailCtrl implements LocaleAware { } setCurrentlyViewedRecipe(recipe); }); - langSelector.getItems().addAll("en", "nl", "pl", "tok"); + langSelector.getItems().addAll(Config.languages); } public void handleAddAllToShoppingList(ActionEvent actionEvent) { diff --git a/client/src/main/java/client/utils/Config.java b/client/src/main/java/client/utils/Config.java index a70d55c..d3c3823 100644 --- a/client/src/main/java/client/utils/Config.java +++ b/client/src/main/java/client/utils/Config.java @@ -5,6 +5,7 @@ import java.util.List; public class Config { private String language = "en"; + public static String[] languages = {"en", "nl", "pl", "tok", "zhc", "zht"}; private List recipeLanguages = new ArrayList<>(); private String serverUrl = "http://localhost:8080"; From 6d4c33fa10e63c7e3023dfa4333724aa3e4f6184 Mon Sep 17 00:00:00 2001 From: Aysegul Aydinlik Date: Fri, 23 Jan 2026 02:55:27 +0100 Subject: [PATCH 47/55] finished 4.5 last remaining points --- .../scenes/recipe/RecipeDetailCtrl.java | 50 +++- .../scenes/shopping/AddOverviewCtrl.java | 244 ++++++++++++++++++ .../scenes/shopping/ShoppingListCtrl.java | 48 +++- .../service/ShoppingListServiceImpl.java | 65 ++++- .../client/scenes/shopping/AddOverview.fxml | 32 +++ .../client/scenes/shopping/ShoppingList.fxml | 2 + 6 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 client/src/main/java/client/scenes/shopping/AddOverviewCtrl.java create mode 100644 client/src/main/resources/client/scenes/shopping/AddOverview.fxml diff --git a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java index d923ffd..cacccb9 100644 --- a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java +++ b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java @@ -24,6 +24,8 @@ import java.util.function.Consumer; import javafx.beans.binding.Bindings; import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; @@ -37,6 +39,8 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.scene.text.Font; import javafx.stage.DirectoryChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; /** * Controller for the recipe detail view. @@ -132,7 +136,6 @@ public class RecipeDetailCtrl implements LocaleAware { * * @throws IOException Upon invalid recipe response. * @throws InterruptedException Upon request interruption. - * * @see FoodpalApplicationCtrl#refresh() */ private void refresh() throws IOException, InterruptedException { @@ -171,9 +174,9 @@ public class RecipeDetailCtrl implements LocaleAware { this.recipeView = new ScalableRecipeView(recipe, scale); // TODO i18n inferredKcalLabel.textProperty().bind(Bindings.createStringBinding(() -> - String.format("Inferred %.1f kcal/100g for this recipe", - Double.isNaN(this.recipeView.scaledKcalProperty().get()) ? - 0.0 : this.recipeView.scaledKcalProperty().get()) + String.format("Inferred %.1f kcal/100g for this recipe", + Double.isNaN(this.recipeView.scaledKcalProperty().get()) ? + 0.0 : this.recipeView.scaledKcalProperty().get()) , this.recipeView.scaledKcalProperty())); recipeView.servingsProperty().set(servingsSpinner.getValue()); inferredServeSizeLabel.textProperty().bind(Bindings.createStringBinding( @@ -197,7 +200,7 @@ public class RecipeDetailCtrl implements LocaleAware { * @param recipeConsumer The helper function to use when updating the recipe - * how to update it * @return The created callback to use in a setUpdateCallback or related - * function + * function */ private Consumer createUpdateRecipeCallback(BiConsumer recipeConsumer) { return recipes -> { @@ -337,6 +340,7 @@ public class RecipeDetailCtrl implements LocaleAware { PrintExportService.exportToFile(recipeText, dirPath, filename); } } + /** * Toggles the favourite status of the currently viewed recipe in the * application configuration and writes the changes to disk. @@ -425,12 +429,36 @@ public class RecipeDetailCtrl implements LocaleAware { langSelector.getItems().addAll(Config.languages); } + @FXML public void handleAddAllToShoppingList(ActionEvent actionEvent) { - System.out.println("handleAddAllToShoppingList"); - // TODO BACKLOG Add overview screen - recipe.getIngredients().stream() - .filter(x -> x.getClass().equals(FormalIngredient.class)) - .map(FormalIngredient.class::cast) - .forEach(x -> shoppingListService.putIngredient(x, recipe)); + Recipe ingredientSource = (recipeView != null) ? recipeView.getScaled() : recipe; + + var ingredients = ingredientSource.getIngredients().stream() + .map(ri -> { + if (ri instanceof FormalIngredient fi) { + return fi; + } + return new FormalIngredient( + ri.getIngredient(), + 1, + "" + ); + }) + .toList(); + + var pair = client.UI.getFXML().load( + client.scenes.shopping.AddOverviewCtrl.class, + "client", "scenes", "shopping", "AddOverview.fxml" + ); + + var ctrl = pair.getKey(); + ctrl.setContext(recipe, ingredients); + + Stage stage = new Stage(); + stage.setTitle("Add to shopping list"); + stage.initModality(Modality.WINDOW_MODAL); + stage.initOwner(((Node) actionEvent.getSource()).getScene().getWindow()); + stage.setScene(new Scene(pair.getValue())); + stage.showAndWait(); } } diff --git a/client/src/main/java/client/scenes/shopping/AddOverviewCtrl.java b/client/src/main/java/client/scenes/shopping/AddOverviewCtrl.java new file mode 100644 index 0000000..113831b --- /dev/null +++ b/client/src/main/java/client/scenes/shopping/AddOverviewCtrl.java @@ -0,0 +1,244 @@ +package client.scenes.shopping; + +import client.service.ShoppingListService; +import client.utils.LocaleAware; +import client.utils.LocaleManager; +import com.google.inject.Inject; +import commons.FormalIngredient; +import commons.Ingredient; +import commons.Recipe; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextInputDialog; +import javafx.scene.control.cell.ComboBoxTableCell; +import javafx.scene.control.cell.TextFieldTableCell; +import javafx.stage.Stage; +import javafx.util.converter.DoubleStringConverter; + +import java.util.List; +import java.util.Optional; + + + +public class AddOverviewCtrl implements LocaleAware { + + private final ShoppingListService shoppingListService; + private final LocaleManager localeManager; + + + private String sourceRecipeName; + + private final ObservableList rows = FXCollections.observableArrayList(); + + @FXML + private TableView overviewTable; + + @FXML + private TableColumn nameColumn; + + @FXML + private TableColumn amountColumn; + + @FXML + private TableColumn unitColumn; + + @FXML + public void initialize() { + initializeComponents(); + } + + private static final List DEFAULT_UNITS = + List.of("", "g", "kg", "ml", "l", "tbsp"); + + @Inject + public AddOverviewCtrl(ShoppingListService shoppingListService, LocaleManager localeManager) { + this.shoppingListService = shoppingListService; + this.localeManager = localeManager; + } + + @Override + public void initializeComponents() { + overviewTable.setEditable(true); + + nameColumn.setCellValueFactory(c -> c.getValue().nameProperty()); + amountColumn.setCellValueFactory(c -> c.getValue().amountProperty().asObject()); + unitColumn.setCellValueFactory(c -> c.getValue().unitProperty()); + + nameColumn.setCellFactory(TextFieldTableCell.forTableColumn()); + nameColumn.setOnEditCommit(e -> e.getRowValue().setName(e.getNewValue())); + + amountColumn.setCellFactory(TextFieldTableCell.forTableColumn(new DoubleStringConverter())); + amountColumn.setOnEditCommit(e -> e.getRowValue().setAmount( + e.getNewValue() == null ? 0.0 : e.getNewValue() + )); + + var unitOptions = FXCollections.observableArrayList(DEFAULT_UNITS); + unitColumn.setCellFactory(ComboBoxTableCell.forTableColumn(unitOptions)); + unitColumn.setOnEditCommit(e -> e.getRowValue().setUnit(e.getNewValue())); + + overviewTable.setItems(rows); + } + + @Override + public void updateText() { + } + + @Override + public LocaleManager getLocaleManager() { + return localeManager; + } + + public void setContext(Recipe recipe, List ingredients) { + this.sourceRecipeName = recipe == null ? null : recipe.getName(); + + rows.clear(); + for (FormalIngredient fi : ingredients) { + rows.add(AddOverviewRow.fromFormalIngredient(fi)); + } + } + + @FXML + private void handleAddRow() { + TextInputDialog nameDialog = new TextInputDialog(); + nameDialog.setTitle("Add item"); + nameDialog.setHeaderText("Add an ingredient"); + nameDialog.setContentText("Name:"); + + Optional nameOpt = nameDialog.showAndWait(); + if (nameOpt.isEmpty() || nameOpt.get().isBlank()) { + return; + } + + TextInputDialog amountDialog = new TextInputDialog("0"); + amountDialog.setTitle("Add item"); + amountDialog.setHeaderText("Amount"); + amountDialog.setContentText("Amount (number):"); + + double amount = 0.0; + Optional amountOpt = amountDialog.showAndWait(); + if (amountOpt.isPresent()) { + try { + amount = Double.parseDouble(amountOpt.get().trim()); + } catch (NumberFormatException ignored) { + amount = 0.0; + } + } + + rows.add(AddOverviewRow.arbitrary(nameOpt.get().trim(), amount, "")); + overviewTable.getSelectionModel().selectLast(); + overviewTable.scrollTo(rows.size() - 1); + } + + @FXML + private void handleRemoveSelected() { + AddOverviewRow selected = overviewTable.getSelectionModel().getSelectedItem(); + if (selected == null) { + return; + } + rows.remove(selected); + } + + @FXML + private void handleConfirm() { + for (AddOverviewRow row : rows) { + FormalIngredient fi = row.toFormalIngredient(); + if (sourceRecipeName == null || sourceRecipeName.isBlank()) { + shoppingListService.putIngredient(fi); + } else { + shoppingListService.putIngredient(fi, sourceRecipeName); + } + } + closeWindow(); + } + + @FXML + private void handleCancel() { + closeWindow(); + } + + private void closeWindow() { + Stage stage = (Stage) overviewTable.getScene().getWindow(); + stage.close(); + } + + public static class AddOverviewRow { + private Ingredient backingIngredient; + + private final StringProperty name = new SimpleStringProperty(""); + private final DoubleProperty amount = new SimpleDoubleProperty(0.0); + private final StringProperty unit = new SimpleStringProperty(""); + + public static AddOverviewRow fromFormalIngredient(FormalIngredient fi) { + AddOverviewRow r = new AddOverviewRow(); + r.backingIngredient = fi.getIngredient(); + r.name.set(fi.getIngredient().getName()); + r.amount.set(fi.getAmount()); + r.unit.set(fi.getUnitSuffix() == null ? "" : fi.getUnitSuffix()); + return r; + } + + public static AddOverviewRow arbitrary(String name, double amount, String unit) { + AddOverviewRow r = new AddOverviewRow(); + r.backingIngredient = null; + r.name.set(name == null ? "" : name); + r.amount.set(amount); + r.unit.set(unit == null ? "" : unit); + return r; + } + + public FormalIngredient toFormalIngredient() { + Ingredient ing = backingIngredient; + + if (ing == null) { + ing = new Ingredient(getName(), 0.0, 0.0, 0.0); + } else { + ing.setName(getName()); + } + + return new FormalIngredient(ing, getAmount(), getUnit()); + } + + public StringProperty nameProperty() { + return name; + } + + public DoubleProperty amountProperty() { + return amount; + } + + public StringProperty unitProperty() { + return unit; + } + + public String getName() { + return name.get(); + } + + public void setName(String name) { + this.name.set(name == null ? "" : name); + } + + public double getAmount() { + return amount.get(); + } + + public void setAmount(double amount) { + this.amount.set(amount); + } + + public String getUnit() { + return unit.get(); + } + + public void setUnit(String unit) { + this.unit.set(unit == null ? "" : unit); + } + } +} diff --git a/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java b/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java index ec08c62..1367936 100644 --- a/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java +++ b/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java @@ -4,6 +4,7 @@ import client.UI; import client.service.ShoppingListService; import client.utils.LocaleAware; import client.utils.LocaleManager; +import client.utils.PrintExportService; import com.google.inject.Inject; import commons.FormalIngredient; import javafx.event.ActionEvent; @@ -11,11 +12,14 @@ import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.Scene; +import javafx.scene.control.Alert; import javafx.scene.control.ListView; +import javafx.stage.FileChooser; import javafx.stage.Modality; import javafx.stage.Stage; import javafx.util.Pair; +import java.io.File; import java.util.Optional; public class ShoppingListCtrl implements LocaleAware { @@ -47,10 +51,9 @@ public class ShoppingListCtrl implements LocaleAware { public void initializeComponents() { this.shoppingListView.setEditable(true); this.shoppingListView.setCellFactory(l -> new ShoppingListCell()); - this.shoppingListView.getItems().setAll( - this.shopping.getItems() - ); + this.shoppingListView.setItems(this.shopping.getViewModel().getListItems()); } + private void refreshList() { this.shoppingListView.getItems().setAll( this.shopping.getItems() @@ -63,14 +66,14 @@ public class ShoppingListCtrl implements LocaleAware { "client", "scenes", "shopping", "ShoppingListItemAddModal.fxml"); root.getKey().setNewValueConsumer(fi -> { this.shopping.putIngredient(fi); - refreshList(); + }); stage.setScene(new Scene(root.getValue())); stage.setTitle("My modal window"); stage.initModality(Modality.WINDOW_MODAL); stage.initOwner( ((Node)actionEvent.getSource()).getScene().getWindow() ); - stage.show(); + stage.showAndWait(); } public void handleRemoveItem(ActionEvent actionEvent) { @@ -78,4 +81,39 @@ public class ShoppingListCtrl implements LocaleAware { this.shopping.getItems().remove(x); refreshList(); } + + public void handleReset(ActionEvent actionEvent) { + shopping.reset(); + } + + public void handlePrint(ActionEvent actionEvent) { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Save Shopping List"); + fileChooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Text Files", "*.txt") + ); + fileChooser.setInitialFileName("shopping-list.txt"); + + File file = fileChooser.showSaveDialog( + ((Node) actionEvent.getSource()).getScene().getWindow() + ); + + if (file == null) { + return; + } + + try { + PrintExportService.exportToFile( + shopping.makePrintable(), + file.getParentFile().toPath(), + file.getName() + ); + } catch (Exception e) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Failed to save shopping list"); + alert.setContentText(e.getMessage()); + alert.showAndWait(); + } + } } diff --git a/client/src/main/java/client/service/ShoppingListServiceImpl.java b/client/src/main/java/client/service/ShoppingListServiceImpl.java index 9941ee6..21c16f2 100644 --- a/client/src/main/java/client/service/ShoppingListServiceImpl.java +++ b/client/src/main/java/client/service/ShoppingListServiceImpl.java @@ -37,20 +37,45 @@ public class ShoppingListServiceImpl extends ShoppingListService { @Override public void putArbitraryItem(String name) { - + if (name == null || name.isBlank()) { + return; + } + var ingredient = new commons.Ingredient(name.trim(), 0.0, 0.0, 0.0); + var fi = new commons.FormalIngredient(ingredient, 0.0, ""); + getViewModel().getListItems().add(new Pair<>(fi, Optional.empty())); } @Override public FormalIngredient purgeIngredient(Long id) { + if (id == null) { + return null; + } + + for (var item : getViewModel().getListItems()) { + FormalIngredient fi = item.getKey(); + if (fi != null && fi.getId() != null && fi.getId().equals(id)) { + getViewModel().getListItems().remove(item); + return fi; + } + } return null; } @Override public FormalIngredient purgeIngredient(String ingredientName) { - FormalIngredient fi = getViewModel().getListItems().stream() - .filter(i -> - i.getKey().getIngredient().getName().equals(ingredientName)) - .findFirst().orElseThrow(NullPointerException::new).getKey(); + if (ingredientName == null) { + return null; + } + + for (var item : getViewModel().getListItems()) { + FormalIngredient fi = item.getKey(); + if (fi != null + && fi.getIngredient() != null + && ingredientName.equals(fi.getIngredient().getName())) { + getViewModel().getListItems().remove(item); + return fi; + } + } return null; } @@ -66,6 +91,34 @@ public class ShoppingListServiceImpl extends ShoppingListService { @Override public String makePrintable() { - return "TODO"; + StringBuilder sb = new StringBuilder(); + + for (var item : getViewModel().getListItems()) { + FormalIngredient ingredient = item.getKey(); + Optional source = item.getValue(); + + if (ingredient == null || ingredient.getIngredient() == null) { + continue; + } + + sb.append(ingredient.getIngredient().getName()); + + if (ingredient.getAmount() > 0) { + sb.append(" - ") + .append(ingredient.getAmount()); + + if (ingredient.getUnitSuffix() != null && !ingredient.getUnitSuffix().isBlank()) { + sb.append(ingredient.getUnitSuffix()); + } + } + + source.ifPresent(recipe -> + sb.append(" (").append(recipe).append(")") + ); + + sb.append(System.lineSeparator()); + } + + return sb.toString(); } } diff --git a/client/src/main/resources/client/scenes/shopping/AddOverview.fxml b/client/src/main/resources/client/scenes/shopping/AddOverview.fxml new file mode 100644 index 0000000..7fb31f5 --- /dev/null +++ b/client/src/main/resources/client/scenes/shopping/AddOverview.fxml @@ -0,0 +1,32 @@ + + + + + + + + + +
From b5348a4c24e17d00a37ddbedf5c830e7d8beeebc Mon Sep 17 00:00:00 2001 From: Maria Dumitrescu Date: Fri, 23 Jan 2026 09:27:13 +0100 Subject: [PATCH 48/55] feedback w8 --- docs/feedback/week-08.md | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/feedback/week-08.md diff --git a/docs/feedback/week-08.md b/docs/feedback/week-08.md new file mode 100644 index 0000000..8826bb1 --- /dev/null +++ b/docs/feedback/week-08.md @@ -0,0 +1,60 @@ + + +# Meeting feedback + + +**Quick Summary Scale** +Insufficient/Sufficient/Good/Excellent + + +#### Agenda +Feedback: **Excellent** + +- The agenda was added on time to the repository. +- The agenda follows the required format. +- The individual points of discussion are clear and detailed. +- I liked that you kept the separate sections template for the agenda. + +#### Performance of the Previous Minute Taker +Feedback: **Excellent** + +- The notes have been merged to the agenda file. +- The notes are plentiful, detailed and clear. +- They contain concrete and realistic agreements. +- Everyone was assigned a task. + + +#### Chair performance +Feedback: **Excellent** + +- You worked through your agenda well and carried the meeting forward. +- You engaged everyone into the conversation. +- Time estimates seemed accurate. +- I liked that you started with a brief summary of all feedbacks. +- You listened to everyone and also ensured that they agree with you. + + +#### Attitude & Relation +Feedback: **Excellent** + +- Everyone took good ownership of the meeting and participated equally. +- Everyone was active and listened to all ideas. +- The atmosphere was practical and constructive. +- Everyone provided feedback on the agreements. + + +#### Potentially Shippable Product +Feedback: **Excellent** + +- You presented the current shippable product during the meeting. +- There was great progress compared to last week. +- You are on a good track to create a fully working application by the end. + + +#### Work Contribution/Distribution in the Team +Feedback: **Excellent** + +- You discussed the action points of the last week and your progress on them. +- Everyone reached their goal. +- Everyone has contributed to the project. +- I liked that you distributed all remaining tasks and set an internal deadline for the project. \ No newline at end of file From 2c54833c6b1738246da2f1e0629a35518baa55d8 Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Fri, 23 Jan 2026 12:18:34 +0100 Subject: [PATCH 49/55] docs: agenda for week 09 --- docs/feedback/week-08.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/feedback/week-08.md b/docs/feedback/week-08.md index 8826bb1..03103e6 100644 --- a/docs/feedback/week-08.md +++ b/docs/feedback/week-08.md @@ -57,4 +57,4 @@ Feedback: **Excellent** - You discussed the action points of the last week and your progress on them. - Everyone reached their goal. - Everyone has contributed to the project. -- I liked that you distributed all remaining tasks and set an internal deadline for the project. \ No newline at end of file +- I liked that you distributed all remaining tasks and set an internal deadline for the project. From a5cdcaac14317576edc386414c09008d98174814 Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Fri, 23 Jan 2026 12:20:03 +0100 Subject: [PATCH 50/55] docs: agenda for week 09 --- docs/agenda/agenda-09.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/agenda/agenda-09.md diff --git a/docs/agenda/agenda-09.md b/docs/agenda/agenda-09.md new file mode 100644 index 0000000..2866510 --- /dev/null +++ b/docs/agenda/agenda-09.md @@ -0,0 +1,36 @@ +# Week 9 meeting agenda + +| Key | Value | +| ------------ |----------------------------------------------------------------------------------------------------------------| +| Date | Jan 23rd 2026 | +| Time | 16:45 | +| Location | DW PC Hall 2 | +| Chair | Natalia Cholewa | +| Minute Taker | Steven Liu | +| Attendees | Natalia Cholewa, Oskar Rasieński, 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? +4. (1 min) Buddycheck reminder - January 30th + +### Final check-ups +5. (15 min) Go over all everything we've done and check how close we are to completion with every feature. + * (5 min) Progress check regarding this week + * (3 min) Showcase of the (almost final) version + +### Planning +6. (5 min) Assign the very last features to implement today. + * This can be as simple as going over them as well. + * Code freeze is **today**. Make sure every issue is closed, every merge request is resolved and every other branch is deleted. + +### Closing +7. (2 min) Questions? +8. (2 min) Summarize everything +9. (1 min) TA remarks + +Estimated meeting time: ~25-30 minutes From 91a8a133691c456de97ef90fe8571f3ac27586c3 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Fri, 23 Jan 2026 15:05:15 +0100 Subject: [PATCH 51/55] chore: update README to express implemented features --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 946ee4a..010c666 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,27 @@ The project uses Java 25. Make sure you have the correct Java version installed. ### Client -The client needs to be launched **after** a server is already running, see Usage.Server section: - ``` mvn -pl client -am javafx:run ``` ### Server -By default, the server listens to the port `8080`. -[TODO(1)]:: Configurable port. - +By default, the server listens to the port `8080`. If the server is not available, the next immediate free port is opened. You can run the server with: ``` mvn -pl server -am spring-boot:run ``` -## Features +## Implemented 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. +- Full 4.1 basic requirements criteria. Print/Export functionality exports a plain-text file of the recipe to a file in a location of the user's choosing. +- Full 4.2 WebSocket modelling. We have implemented all points in the backlog and created a compliant solution using distinct STOMP messages that distribute handling to multiple distinct callbacks. We also implemented a meaningful addition as an "Updated at {Time}" utility prompt such that the application hints to the user when it may be time to perform a manual refresh to stay up-to-date. +- Full 4.3 Nutrition view and list of ingredients. We have implemented each criteria in the product backlog pertaining to this section to a satisfactory level. +When in confusion, the user should consult Manual.Ingredients section of this file to see how to add an informal or formal ingredient. +We also include a meaningful addition into the application as a pie chart describing the nutritional composition of each recipe between proteins, carbohydrates, and fats, so that the end user gets a much more straightforward presentation for their dietary choices. +- Full 4.4 search functionality implemented. Each criteria is met to satisfactory standards and the client submits a search query to the backend with a list of parameters, to which the client proceeds to respond. +- Full 4.5 Shopping list functionality. We implemented a functional shopping list to which the user can add/delete/edit ingredients to, as well as an Add Overview when the user decides to add ingredients of a recipe into their shopping list. Printing the list to a file is also supported. +- Full 4.6 functionality. All parts of the UI buttons have been linked to their respective resource items. The application has support for 7 languages, including English, Dutch, Chinese (Simplified/Traditional), Polish, Turkish, and toki pona. We include a national flag for each language in a language selection menu for easier interaction with the end user. ## Manual @@ -58,5 +58,5 @@ The configuration is with JSON, read from `config.json` in the working directory ### 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 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 from the dropdown. - To input an **informal** ingredient, describe the amount in the first input box, like "some of", or "a sprinkle of", then select "" in the unit selection box, and write the name of your ingredient or pick from one of the availables from the dropdown. From c1301dad28cf34af5a6e0c8485d0873fa0e5c1f7 Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Fri, 23 Jan 2026 13:48:48 +0100 Subject: [PATCH 52/55] feat: pie chart for nutrients --- .../nutrition/NutritionPieChartCtrl.java | 210 ++++++++++++++++++ .../scenes/recipe/RecipeDetailCtrl.java | 7 + .../scenes/nutrition/NutritionPieChart.fxml | 6 + .../scenes/recipe/RecipeDetailView.fxml | 9 +- 4 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 client/src/main/java/client/scenes/nutrition/NutritionPieChartCtrl.java create mode 100644 client/src/main/resources/client/scenes/nutrition/NutritionPieChart.fxml diff --git a/client/src/main/java/client/scenes/nutrition/NutritionPieChartCtrl.java b/client/src/main/java/client/scenes/nutrition/NutritionPieChartCtrl.java new file mode 100644 index 0000000..40d869b --- /dev/null +++ b/client/src/main/java/client/scenes/nutrition/NutritionPieChartCtrl.java @@ -0,0 +1,210 @@ +package client.scenes.nutrition; + +import client.utils.LocaleAware; +import client.utils.LocaleManager; +import com.google.inject.Inject; +import commons.FormalIngredient; +import commons.Ingredient; +import commons.Recipe; +import commons.VagueIngredient; +import javafx.fxml.FXML; +import javafx.scene.chart.PieChart; + +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; + +public class NutritionPieChartCtrl implements LocaleAware { + /** + * Nutrition info for a recipe or an ingredient. + * + * @param protein The protein this recipe/ingredient has + * @param fat The fat this recipe/ingredient has + * @param carbs The carbs this recipe/ingredient has + */ + private record CompleteNutritionInfo( + double protein, + double fat, + double carbs + ) { + /** + * Create a new {@link CompleteNutritionInfo} with zeroed values. + * + * @return A {@link CompleteNutritionInfo} with zeroed values. + */ + static CompleteNutritionInfo zero() { + return new CompleteNutritionInfo(0.0, 0.0, 0.0); + } + + /** + * Check whether this instance has all values set to zero. + * + * @return Whether all values (protein, carbs, fat) are zero. + */ + public boolean isZero() { + return this.protein() == 0.0 + && this.carbs() == 0.0 + && this.fat() == 0.0; + } + + /** + * Scale this object by an amount. Multiplies all values by amount. + * + * @param amount The amount to scale it by. + * @return The newly scaled nutrition info. + */ + public CompleteNutritionInfo scaled(double amount) { + return new CompleteNutritionInfo( + this.protein() * amount, + this.fat() * amount, + this.carbs() * amount + ); + } + + /** + * Add another nutrition info object to this object. + * + * @param rhs The nutrition info object to add. + * @return A new nutrition info object with the sum of both objects' nutrients. + */ + public CompleteNutritionInfo add(CompleteNutritionInfo rhs) { + return new CompleteNutritionInfo( + this.protein() + rhs.protein(), + this.fat() + rhs.fat(), + this.carbs() + rhs.carbs() + ); + } + } + + private final LocaleManager localeManager; + private final Logger logger = Logger.getLogger(NutritionPieChartCtrl.class.getName()); + + @FXML + PieChart pieChart; + + private Recipe recipe; + + @Inject + NutritionPieChartCtrl( + LocaleManager manager + ) { + this.localeManager = manager; + } + + /** + * Get the data for this pie chart based on the current recipe. + *

+ * This accumulates all the nutrients (properly scaled) from all ingredients in this recipe + * and returns a summary with data points. + *

+ *

+ * The list will be of length 3 if any nutrients are added, or 0 if all the nutrients + * sum up to be 0. + *

+ * @return The data for this pie chart, as a labeled list of data. + */ + private List getPieChartData() { + CompleteNutritionInfo info = this.recipe + .getIngredients() + .stream() + .map(ri -> { + Ingredient i = ri.getIngredient(); + logger.info("Mapping ingredient " + i.toString()); + + switch (ri) { + case FormalIngredient fi -> { + return new CompleteNutritionInfo( + i.getProteinPer100g(), + i.getFatPer100g(), + i.getCarbsPer100g() + ) + .scaled(fi.getBaseAmount()); + } + case VagueIngredient vi -> { + return new CompleteNutritionInfo( + i.getProteinPer100g(), + i.getFatPer100g(), + i.getCarbsPer100g() + ) + .scaled(vi.getBaseAmount()); + } + default -> { + return null; + } + } + }) + .filter(Objects::nonNull) + .reduce(CompleteNutritionInfo::add) + .orElseGet(CompleteNutritionInfo::zero); + + this.logger.info( "Updated data: " + info.toString() ); + + if (info.isZero()) { + return List.of(); + } + + return List.of( + new PieChart.Data( + "Protein", + info.protein() + ), + new PieChart.Data( + "Fat", + info.fat() + ), + new PieChart.Data( + "Carbs", + info.carbs() + ) + ); + } + + /** + * Set the current recipe to be displayed in the pie chart. + * The pie chart will be refreshed. + *

+ * The pie chart will disappear if all the nutrients in the recipe + * are zero. + *

+ * @param recipe The recipe to display. + */ + public void setRecipe(Recipe recipe) { + this.recipe = recipe; + this.refresh(); + } + + @Override + public void updateText() { + + } + + @Override + public LocaleManager getLocaleManager() { + return this.localeManager; + } + + /** + * Refresh the data in this pie chart. + */ + private void refresh() { + if (this.recipe == null) { + this.pieChart.setVisible(false); + logger.info("Refreshing pie chart with no recipe"); + return; + } + + logger.info("Refreshing pie chart with recipe"); + this.pieChart.setVisible(true); + this.pieChart.getData().setAll( + this.getPieChartData() + ); + } + + public void initializeComponents() { + final double START_ANGLE = 60.0; + + this.pieChart.setClockwise(true); + this.pieChart.setStartAngle(START_ANGLE); + this.refresh(); + } +} diff --git a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java index cacccb9..37c8d50 100644 --- a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java +++ b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java @@ -2,6 +2,7 @@ package client.scenes.recipe; import client.exception.UpdateException; import client.scenes.FoodpalApplicationCtrl; +import client.scenes.nutrition.NutritionPieChartCtrl; import client.service.ShoppingListService; import client.utils.Config; import client.utils.ConfigService; @@ -106,6 +107,9 @@ public class RecipeDetailCtrl implements LocaleAware { @FXML private ComboBox langSelector; + @FXML + private NutritionPieChartCtrl pieChartController; + private ListView getParentRecipeList() { return this.appCtrl.recipeList; } @@ -185,11 +189,14 @@ public class RecipeDetailCtrl implements LocaleAware { // expose the scaled view to list controllers this.ingredientListController.refetchFromRecipe(this.recipeView.getScaled()); this.stepListController.refetchFromRecipe(this.recipeView.getScaled()); + this.pieChartController.setRecipe(recipe); return; } this.ingredientListController.refetchFromRecipe(recipe); this.stepListController.refetchFromRecipe(recipe); + + this.pieChartController.setRecipe(recipe); } /** diff --git a/client/src/main/resources/client/scenes/nutrition/NutritionPieChart.fxml b/client/src/main/resources/client/scenes/nutrition/NutritionPieChart.fxml new file mode 100644 index 0000000..d63531b --- /dev/null +++ b/client/src/main/resources/client/scenes/nutrition/NutritionPieChart.fxml @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml b/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml index 602b827..355486c 100644 --- a/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml +++ b/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml @@ -42,6 +42,11 @@ -