From 042a43e06a366a013b78125844e2753cbb555f06 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Tue, 6 Jan 2026 18:06:40 +0100 Subject: [PATCH] feat(client/ws): integrate changes from main and create client-side WS service --- client/src/main/java/client/MyModule.java | 11 +- .../InvalidModificationException.java | 7 ++ .../client/scenes/FoodpalApplicationCtrl.java | 115 +++++++++++++----- .../scenes/recipe/RecipeDetailCtrl.java | 35 ++++-- .../main/java/client/utils/ServerUtils.java | 3 +- .../client/utils/WebSocketDataService.java | 58 +++++++++ .../java/client/scenes/PrintExportTest.java | 2 +- 7 files changed, 188 insertions(+), 43 deletions(-) create mode 100644 client/src/main/java/client/exception/InvalidModificationException.java create mode 100644 client/src/main/java/client/utils/WebSocketDataService.java diff --git a/client/src/main/java/client/MyModule.java b/client/src/main/java/client/MyModule.java index 0afc5f9..668c62d 100644 --- a/client/src/main/java/client/MyModule.java +++ b/client/src/main/java/client/MyModule.java @@ -22,12 +22,16 @@ import client.scenes.recipe.RecipeStepListCtrl; import client.utils.ConfigService; import client.utils.LocaleManager; import client.utils.ServerUtils; +import client.utils.WebSocketDataService; import client.utils.WebSocketUtils; import com.google.inject.Binder; import com.google.inject.Module; import com.google.inject.Scopes; import client.scenes.MainCtrl; +import com.google.inject.TypeLiteral; +import commons.Ingredient; +import commons.Recipe; import java.nio.file.Path; @@ -45,6 +49,11 @@ public class MyModule implements Module { binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON); binder.bind(ConfigService.class).toInstance(new ConfigService(Path.of("config.json"))); - + binder.bind(new TypeLiteral>() {}).toInstance( + new WebSocketDataService<>() + ); + binder.bind(new TypeLiteral>() {}).toInstance( + new WebSocketDataService<>() + ); } } \ No newline at end of file diff --git a/client/src/main/java/client/exception/InvalidModificationException.java b/client/src/main/java/client/exception/InvalidModificationException.java new file mode 100644 index 0000000..ec589a0 --- /dev/null +++ b/client/src/main/java/client/exception/InvalidModificationException.java @@ -0,0 +1,7 @@ +package client.exception; + +public class InvalidModificationException extends RuntimeException { + public InvalidModificationException(String message) { + super(message); + } +} diff --git a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java index 37541cb..502706d 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -5,8 +5,10 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; +import client.exception.InvalidModificationException; import client.scenes.recipe.RecipeDetailCtrl; import client.utils.Config; @@ -15,11 +17,16 @@ import client.utils.DefaultValueFactory; import client.utils.LocaleAware; import client.utils.LocaleManager; import client.utils.ServerUtils; +import client.utils.WebSocketDataService; import client.utils.WebSocketUtils; import commons.Recipe; import commons.ws.Topics; +import commons.ws.messages.CreateRecipeMessage; +import commons.ws.messages.DeleteRecipeMessage; +import commons.ws.messages.FavouriteRecipeMessage; import commons.ws.messages.Message; +import commons.ws.messages.UpdateRecipeMessage; import jakarta.inject.Inject; import javafx.application.Platform; import javafx.fxml.FXML; @@ -29,11 +36,14 @@ import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; import javafx.scene.control.ToggleButton; +import org.apache.commons.lang3.NotImplementedException; +import org.apache.commons.lang3.tuple.ImmutablePair; public class FoodpalApplicationCtrl implements LocaleAware { private final ServerUtils server; private final WebSocketUtils webSocketUtils; private final LocaleManager localeManager; + private final WebSocketDataService dataService; @FXML private RecipeDetailCtrl recipeDetailController; @@ -72,14 +82,54 @@ public class FoodpalApplicationCtrl implements LocaleAware { ServerUtils server, WebSocketUtils webSocketUtils, LocaleManager localeManager, - ConfigService configService + ConfigService configService, + WebSocketDataService recipeDataService ) { this.server = server; this.webSocketUtils = webSocketUtils; this.localeManager = localeManager; this.configService = configService; - + this.dataService = recipeDataService; + recipeDataService.setMessageParser((msg) -> switch (msg) { + case CreateRecipeMessage _ -> (m) -> { + CreateRecipeMessage crm = (CreateRecipeMessage) m; + this.recipeList.getItems().add(crm.getRecipe()); + dataService.add(crm.getRecipe().getId()); + return new ImmutablePair<>(crm.getRecipe().getId(), crm.getRecipe()); + }; + case UpdateRecipeMessage _ -> (m) -> { + UpdateRecipeMessage upm = (UpdateRecipeMessage) m; + Recipe recipe = upm.getRecipe(); + System.out.println("recipe: " + recipe.getId() + " of content: " + recipe); + // Discover the first (and should be ONLY) instance of the recipe with the specified ID. + // if not found, throw new InvalidModificationException with a message. + this.recipeList.getItems().set( + this.recipeList.getItems().indexOf( + findRecipeById(recipe.getId()) + .orElseThrow( + () -> new InvalidModificationException("Invalid recipe id during update: " + recipe.getId())) + ), + recipe + ); + System.out.println(recipe.getId()); + dataService.add(recipe.getId()); + return new ImmutablePair<>(recipe.getId(), recipe); + }; + case DeleteRecipeMessage _ -> (m) -> { + DeleteRecipeMessage drm = (DeleteRecipeMessage) m; + this.recipeList.getItems().remove(findRecipeById(drm.getRecipeId()).orElseThrow( + () -> new InvalidModificationException("Invalid recipe id during delete: " + drm.getRecipeId()) + )); + dataService.add(drm.getRecipeId()); + // TODO Make it an Optional so that we don't need to touch Null? + return new ImmutablePair<>(drm.getRecipeId(), null); + }; + case FavouriteRecipeMessage _ -> (m) -> { + throw new NotImplementedException("TODO:: implement favourited recipe deletion warning"); + }; + default -> throw new IllegalStateException("Unexpected value: " + msg); + }); initializeWebSocket(); } @@ -129,13 +179,17 @@ public class FoodpalApplicationCtrl implements LocaleAware { } }); } - + private Optional findRecipeById(Long id) { + return this.recipeList.getItems().stream() + .filter(r -> r.getId().equals(id)) + .findFirst(); + } private void initializeWebSocket() { webSocketUtils.connect(() -> { - webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> { + webSocketUtils.subscribe(Topics.RECIPES, (Message msg) -> { Platform.runLater(() -> { + dataService.onMessage(msg); Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem(); - refresh(); // refresh the left list if (selectedRecipe == null) { return; } @@ -220,15 +274,31 @@ public class FoodpalApplicationCtrl implements LocaleAware { Recipe newRecipe = DefaultValueFactory.getDefaultRecipe(); // Create default recipe try { newRecipe = server.addRecipe(newRecipe); // get the new recipe id - refresh(); // refresh view with server recipes - - // Select newly created recipe - recipeList.getSelectionModel().select(recipeList.getItems().indexOf(newRecipe)); + // Map of pending item updated, indexed by recipe id. + dataService.add(newRecipe.getId(), recipe -> { + this.recipeList.getSelectionModel().select(recipe); + // Select newly created recipe + // FIXME(1) Edit box isn't auto created anymore for some weird reason + openSelectedRecipe(); + this.recipeDetailController.editRecipeTitle(); + }); + recipeList.refresh(); } catch (IOException | InterruptedException e) { printError("Error occurred when adding recipe!"); } - // Calls edit after the recipe has been created, seamless name editing - // editRecipeTitle(); + } + + @FXML + public void removeSelectedRecipe() throws IOException, InterruptedException { + Recipe selected = recipeList.getSelectionModel().getSelectedItem(); + if (selected == null) { + return; + } + + server.deleteRecipe(selected.getId()); + dataService.add(selected.getId(), (r) -> { + // nothing to do here. + }); } @FXML @@ -241,22 +311,9 @@ public class FoodpalApplicationCtrl implements LocaleAware { this.recipeDetailController.setVisible(true); } - /** - * Removes the selected recipe from the server and refreshes the recipe list. - */ @FXML - public void removeSelectedRecipe() throws IOException, InterruptedException { - Recipe selected = recipeList.getSelectionModel().getSelectedItem(); - if (selected == null) { - return; - } - - server.deleteRecipe(selected.getId()); - - // prefer a global refresh (or WS-based update upon its impl) - // rather than recipeList.getItems().remove(selected); - // to maintain single source of truth from the server. - refresh(); + private void makePrintable() { + System.out.println("Recipe printed"); } /** @@ -269,8 +326,10 @@ public class FoodpalApplicationCtrl implements LocaleAware { } Recipe cloned = server.cloneRecipe(selected.getId()); - refresh(); - recipeList.getSelectionModel().select(cloned); + dataService.add(cloned.getId(), recipe -> { + recipeList.getSelectionModel().select(cloned); + openSelectedRecipe();} + ); } @FXML diff --git a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java index 423404f..f4d61d2 100644 --- a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java +++ b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java @@ -7,6 +7,7 @@ import client.utils.ConfigService; import client.utils.LocaleAware; import client.utils.LocaleManager; import client.utils.ServerUtils; +import client.utils.WebSocketDataService; import com.google.inject.Inject; import commons.Recipe; import java.io.IOException; @@ -33,6 +34,7 @@ public class RecipeDetailCtrl implements LocaleAware { private final ServerUtils server; private final FoodpalApplicationCtrl appCtrl; private final ConfigService configService; + private final WebSocketDataService webSocketDataService; @FXML private IngredientListCtrl ingredientListController; @@ -43,13 +45,17 @@ public class RecipeDetailCtrl implements LocaleAware { private Recipe recipe; @Inject - public RecipeDetailCtrl(LocaleManager localeManager, ServerUtils server, + public RecipeDetailCtrl( + LocaleManager localeManager, + ServerUtils server, FoodpalApplicationCtrl appCtrl, - ConfigService configService) { + ConfigService configService, + WebSocketDataService webSocketDataService) { this.localeManager = localeManager; this.server = server; this.appCtrl = appCtrl; this.configService = configService; + this.webSocketDataService = webSocketDataService; } @FXML @@ -154,6 +160,10 @@ public class RecipeDetailCtrl implements LocaleAware { try { // propagate changes to server server.updateRecipe(selectedRecipe); + webSocketDataService.add(selectedRecipe.getId(), recipe -> { + int idx = getParentRecipeList().getItems().indexOf(selectedRecipe); + getParentRecipeList().getItems().set(idx, recipe); + }); } catch (IOException | InterruptedException e) { throw new UpdateException("Unable to update recipe to server for " + selectedRecipe); @@ -179,9 +189,10 @@ public class RecipeDetailCtrl implements LocaleAware { * Revised edit recipe control flow, deprecates the use of a separate * AddNameCtrl. This is automagically called when a new recipe is created, * making for a more seamless UX. + * Public for reference by ApplicationCtrl. */ @FXML - private void editRecipeTitle() { + public void editRecipeTitle() { this.editableTitleArea.getChildren().clear(); TextField edit = new TextField(); @@ -217,15 +228,6 @@ public class RecipeDetailCtrl implements LocaleAware { this.appCtrl.removeSelectedRecipe(); } - @FXML - /** - * Print the currently viewed recipe. - */ - private void printRecipe() { - // TODO: actually make it print? - System.out.println("Recipe printed"); - } - /** * Refreshes the favourite button state based on whether the currently viewed * recipe is marked as a favourite in the application configuration. @@ -242,6 +244,15 @@ public class RecipeDetailCtrl implements LocaleAware { : "☆"); } + /** + * Print the currently viewed recipe. + */ + @FXML + private void printRecipe() { + // TODO: actually make it print? + System.out.println("Recipe printed"); + } + /** * Toggles the favourite status of the currently viewed recipe in the * application configuration and writes the changes to disk. diff --git a/client/src/main/java/client/utils/ServerUtils.java b/client/src/main/java/client/utils/ServerUtils.java index 2e99dbc..cb91e76 100644 --- a/client/src/main/java/client/utils/ServerUtils.java +++ b/client/src/main/java/client/utils/ServerUtils.java @@ -2,6 +2,7 @@ package client.utils; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; import commons.Recipe; import commons.RecipeIngredient; import jakarta.ws.rs.ProcessingException; @@ -27,6 +28,7 @@ public class ServerUtils { private final ObjectMapper objectMapper = new ObjectMapper(); private final int statusOK = 200; + @Inject public ServerUtils() { client = HttpClient.newHttpClient(); } @@ -108,7 +110,6 @@ public class ServerUtils { if(response.statusCode() != statusOK){ throw new IOException("Failed to add Recipe: " + newRecipe.toDetailedString() + "body: " + response.body()); } - return objectMapper.readValue(response.body(),Recipe.class); } diff --git a/client/src/main/java/client/utils/WebSocketDataService.java b/client/src/main/java/client/utils/WebSocketDataService.java new file mode 100644 index 0000000..659d91b --- /dev/null +++ b/client/src/main/java/client/utils/WebSocketDataService.java @@ -0,0 +1,58 @@ +package client.utils; + +import client.exception.InvalidModificationException; +import commons.ws.messages.Message; +import org.apache.commons.lang3.function.FailableFunction; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.function.Function; + +public class WebSocketDataService { + public WebSocketDataService() { + + } + + /** + * dataRegister maps a CompletableFuture<T> to its fulfilment function. + */ + private final Map> pendingRegister = new HashMap<>(); + private Function, InvalidModificationException>> parser; + public void setMessageParser( + Function< + Message, + FailableFunction, InvalidModificationException> + > parser) { + this.parser = parser; + } + + /** + * On each WS message, the parser callback distinguishes what + * consumer (crud function) to call. Then the pair of ID-Value + * returned by the processor is used + * @param message + */ + public void onMessage(Message message) { + Pair result = parser.apply(message).apply(message); + pendingRegister.get(result.getKey()).complete(result.getValue()); + } + + /** + * Adds an ID-reference to the map for an object whose update is pending. + * If the key already exists, the caller should assume there is an ongoing + * operation on the object. + * @param id ID of the object. + * @return Whether the key already exists. + */ + public boolean add(ID id, Consumer onComplete) { + CompletableFuture future = new CompletableFuture<>(); + future.thenAccept(onComplete.andThen(_ -> pendingRegister.remove(id))); + return pendingRegister.putIfAbsent(id, future) == null; + } + public boolean add(ID id) { + return add(id, (_) -> {}); + } +} diff --git a/client/src/test/java/client/scenes/PrintExportTest.java b/client/src/test/java/client/scenes/PrintExportTest.java index aee0d8d..3fa93ee 100644 --- a/client/src/test/java/client/scenes/PrintExportTest.java +++ b/client/src/test/java/client/scenes/PrintExportTest.java @@ -39,7 +39,7 @@ public class PrintExportTest { assertEquals(""" Title: Banana Bread Recipe ID: 1234 - Ingredients: some Banana, some Bread,\s + Ingredients: Some Banana, Some Bread,\s Steps: 1: Mix Ingredients 2: Heat in Oven