diff --git a/client/pom.xml b/client/pom.xml
index d48d069..3bd793a 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -34,21 +34,25 @@
jersey-client
${version.jersey}
+
org.glassfish.jersey.inject
jersey-hk2
${version.jersey}
+
org.glassfish.jersey.media
jersey-media-json-jackson
${version.jersey}
+
jakarta.activation
jakarta.activation-api
2.1.3
+
com.google.inject
guice
@@ -61,11 +65,13 @@
javafx-fxml
${version.jfx}
+
org.openjfx
javafx-controls
${version.jfx}
+
org.openjfx
javafx-web
@@ -91,6 +97,27 @@
${version.mockito}
test
+
+
+
+ org.springframework
+ spring-messaging
+ 6.2.12
+ compile
+
+
+
+ org.glassfish.tyrus.bundles
+ tyrus-standalone-client
+ 2.2.1
+
+
+ org.springframework
+ spring-websocket
+ 6.2.12
+ compile
+
+
diff --git a/client/src/main/java/client/MyModule.java b/client/src/main/java/client/MyModule.java
index 4f3464e..21da1dc 100644
--- a/client/src/main/java/client/MyModule.java
+++ b/client/src/main/java/client/MyModule.java
@@ -19,6 +19,8 @@ import client.scenes.FoodpalApplicationCtrl;
import client.scenes.recipe.IngredientListCtrl;
import client.scenes.recipe.RecipeStepListCtrl;
import client.utils.LocaleManager;
+import client.utils.ServerUtils;
+import client.utils.WebSocketUtils;
import com.google.inject.Binder;
import com.google.inject.Module;
import com.google.inject.Scopes;
@@ -33,6 +35,9 @@ public class MyModule implements Module {
binder.bind(FoodpalApplicationCtrl.class).in(Scopes.SINGLETON);
binder.bind(IngredientListCtrl.class).in(Scopes.SINGLETON);
binder.bind(RecipeStepListCtrl.class).in(Scopes.SINGLETON);
+
binder.bind(LocaleManager.class).in(Scopes.SINGLETON);
+ binder.bind(ServerUtils.class).in(Scopes.SINGLETON);
+ binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
}
}
\ No newline at end of file
diff --git a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java
index bcb6f44..69ecd1e 100644
--- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java
+++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java
@@ -13,10 +13,14 @@ import client.utils.DefaultRecipeFactory;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
+import client.utils.WebSocketUtils;
import commons.Recipe;
+import commons.ws.Topics;
+import commons.ws.messages.Message;
import jakarta.inject.Inject;
+import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
@@ -32,6 +36,7 @@ import javafx.scene.text.Font;
public class FoodpalApplicationCtrl implements LocaleAware {
private final ServerUtils server;
+ private final WebSocketUtils webSocketUtils;
private final LocaleManager localeManager;
private final IngredientListCtrl ingredientListCtrl;
private final RecipeStepListCtrl stepListCtrl;
@@ -81,15 +86,42 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@Inject
public FoodpalApplicationCtrl(
ServerUtils server,
+ WebSocketUtils webSocketUtils,
LocaleManager localeManager,
IngredientListCtrl ingredientListCtrl,
RecipeStepListCtrl stepListCtrl
) {
this.server = server;
+ this.webSocketUtils = webSocketUtils;
this.localeManager = localeManager;
this.ingredientListCtrl = ingredientListCtrl;
this.stepListCtrl = stepListCtrl;
+
+ initializeWebSocket();
}
+
+ private void initializeWebSocket() {
+ webSocketUtils.connect(() -> {
+ webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> {
+ Platform.runLater(() -> {
+ Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem();
+ refresh(); // refresh the left list
+ if (selectedRecipe == null) {
+ return;
+ }
+
+ // select last selected recipe if it still exists, first otherwise (done by refresh())
+ Recipe recipeInList = recipeList.getItems().stream()
+ .filter(r -> r.getId().equals(selectedRecipe.getId()))
+ .findFirst()
+ .orElse(null);
+
+ showRecipeDetails(recipeInList);
+ }); // runLater as it's on another non-FX thread.
+ });
+ });
+ }
+
@Override
public void initializeComponents() {
// TODO Reduce code duplication??
@@ -146,6 +178,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
refresh();
}
+
private void showName(String name) {
final int NAME_FONT_SIZE = 20;
editableTitleArea.getChildren().clear();
@@ -153,6 +186,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
nameLabel.setFont(new Font("System Bold", NAME_FONT_SIZE));
editableTitleArea.getChildren().add(nameLabel);
}
+
@Override
public void updateText() {
addRecipeButton.setText(getLocaleString("menu.button.add.recipe"));
@@ -206,11 +240,13 @@ public class FoodpalApplicationCtrl implements LocaleAware {
}
detailsScreen.visibleProperty().set(!recipes.isEmpty());
}
+
private void printError(String msg) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setContentText(msg);
alert.showAndWait();
}
+
/**
* Adds a recipe, by providing a default name "Untitled recipe (n)".
* The UX flow is now:
@@ -283,7 +319,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
try {
server.updateRecipe(selected);
refresh();
- recipeList.getSelectionModel().select(selected);
+ //recipeList.getSelectionModel().select(selected);
} catch (IOException | InterruptedException e) {
// throw a nice blanket UpdateException
throw new UpdateException("Error occurred when updating recipe name!");
@@ -294,7 +330,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
edit.requestFocus();
}
-
// Language buttons
@FXML
private void switchLocale(ActionEvent event) {
diff --git a/client/src/main/java/client/utils/WebSocketUtils.java b/client/src/main/java/client/utils/WebSocketUtils.java
new file mode 100644
index 0000000..ed16b37
--- /dev/null
+++ b/client/src/main/java/client/utils/WebSocketUtils.java
@@ -0,0 +1,111 @@
+package client.utils;
+
+import commons.ws.messages.Message;
+import org.springframework.messaging.converter.MappingJackson2MessageConverter;
+import org.springframework.messaging.simp.stomp.StompSession;
+import org.springframework.messaging.simp.stomp.StompHeaders;
+import org.springframework.messaging.simp.stomp.StompCommand;
+import org.springframework.messaging.simp.stomp.StompFrameHandler;
+import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
+import org.springframework.web.socket.client.standard.StandardWebSocketClient;
+import org.springframework.web.socket.messaging.WebSocketStompClient;
+
+import java.lang.reflect.Type;
+import java.util.function.Consumer;
+import javax.annotation.Nullable;
+import java.util.concurrent.CompletableFuture;
+
+public class WebSocketUtils {
+ private static final String WS_URL = "ws://localhost:8080/updates";
+ private WebSocketStompClient stompClient;
+ private StompSession stompSession;
+
+ /**
+ * Connect to the websocket server.
+ * @param onConnected OnConnected callback
+ */
+ public void connect(@Nullable Runnable onConnected) {
+ StandardWebSocketClient webSocketClient = new StandardWebSocketClient(); // Create WS Client
+
+ stompClient = new WebSocketStompClient(webSocketClient);
+ stompClient.setMessageConverter(new MappingJackson2MessageConverter()); // Use jackson
+
+ CompletableFuture sessionFuture = stompClient.connectAsync(
+ WS_URL,
+ new StompSessionHandlerAdapter() {
+ @Override
+ public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
+ stompSession = session;
+ System.out.println("WebSocket connected: " + session.getSessionId());
+ }
+
+ @Override
+ public void handleException(StompSession session, @Nullable StompCommand command,
+ StompHeaders headers, byte[] payload,
+ Throwable exception) {
+ System.err.println("STOMP error: " + exception.getMessage());
+ exception.printStackTrace();
+ }
+
+ @Override
+ public void handleTransportError(StompSession session, Throwable exception) {
+ System.err.println("STOMP transport error: " + exception.getMessage());
+ exception.printStackTrace();
+ }
+ }
+ );
+
+ sessionFuture.thenAccept(session -> {
+ stompSession = session;
+ System.out.println("Connection successful, session ready");
+ if (onConnected != null) {
+ onConnected.run();
+ }
+ }).exceptionally(throwable -> {
+ System.err.println("Failed to connect: " + throwable.getMessage());
+ throwable.printStackTrace();
+ return null;
+ });
+ }
+
+ /**
+ * Subscribe to a topic.
+ * @param destination Destination to subscribe to, for example: /subscribe/recipe
+ * @param messageHandler Handler for received messages
+ */
+ public void subscribe(String destination, Consumer messageHandler) {
+ if (stompSession == null || !stompSession.isConnected()) {
+ System.err.println("Cannot subscribe - not connected");
+ return;
+ }
+
+ stompSession.subscribe(destination, new StompFrameHandler() {
+ @Override
+ public Type getPayloadType(StompHeaders headers) {
+ return Message.class;
+ }
+
+ @Override
+ public void handleFrame(StompHeaders headers, @Nullable Object payload) {
+ Message message = (Message) payload;
+ messageHandler.accept(message);
+ }
+ });
+ System.out.println("Subscribed to: " + destination);
+ }
+
+ public void disconnect() {
+ if (stompSession != null && stompSession.isConnected()) {
+ stompSession.disconnect();
+ }
+
+ if (stompClient != null) {
+ stompClient.stop();
+ }
+ }
+
+ public boolean isConnected() {
+ return stompSession != null && stompSession.isConnected();
+ }
+
+}
diff --git a/commons/pom.xml b/commons/pom.xml
index e5d5ff4..f48fbcf 100644
--- a/commons/pom.xml
+++ b/commons/pom.xml
@@ -45,6 +45,12 @@
${version.mockito}
test
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.20.1
+ compile
+
diff --git a/commons/src/main/java/commons/ws/messages/CreateIngredientMessage.java b/commons/src/main/java/commons/ws/messages/CreateIngredientMessage.java
index a6392cb..8abd1b9 100644
--- a/commons/src/main/java/commons/ws/messages/CreateIngredientMessage.java
+++ b/commons/src/main/java/commons/ws/messages/CreateIngredientMessage.java
@@ -10,6 +10,8 @@ import commons.Ingredient;
public class CreateIngredientMessage implements Message {
private Ingredient ingredient;
+ public CreateIngredientMessage() {} // for jackson
+
public CreateIngredientMessage(Ingredient ingredient) {
this.ingredient = ingredient;
}
@@ -27,4 +29,9 @@ public class CreateIngredientMessage implements Message {
public Ingredient getIngredient() {
return ingredient;
}
+
+ // for jackson
+ public void setIngredient(Ingredient ingredient) {
+ this.ingredient = ingredient;
+ }
}
diff --git a/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java b/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java
index b036c1f..adc5b28 100644
--- a/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java
+++ b/commons/src/main/java/commons/ws/messages/CreateRecipeMessage.java
@@ -10,6 +10,8 @@ import commons.Recipe;
public class CreateRecipeMessage implements Message {
private Recipe recipe;
+ public CreateRecipeMessage() {} // for jackson
+
public CreateRecipeMessage(Recipe recipe) {
this.recipe = recipe;
}
@@ -27,4 +29,9 @@ public class CreateRecipeMessage implements Message {
public Recipe getRecipe() {
return recipe;
}
+
+ // for jackson
+ public void setRecipe(Recipe recipe) {
+ this.recipe = recipe;
+ }
}
diff --git a/commons/src/main/java/commons/ws/messages/DeleteIngredientMessage.java b/commons/src/main/java/commons/ws/messages/DeleteIngredientMessage.java
index 879df60..fc6985b 100644
--- a/commons/src/main/java/commons/ws/messages/DeleteIngredientMessage.java
+++ b/commons/src/main/java/commons/ws/messages/DeleteIngredientMessage.java
@@ -8,6 +8,8 @@ package commons.ws.messages;
public class DeleteIngredientMessage implements Message {
private Long ingredientId;
+ public DeleteIngredientMessage() {} // for jackson
+
public DeleteIngredientMessage(Long ingredientId) {
this.ingredientId = ingredientId;
}
@@ -25,4 +27,9 @@ public class DeleteIngredientMessage implements Message {
public Long getIngredientId() {
return ingredientId;
}
+
+ // for jackson
+ public void setIngredientId(Long ingredientId) {
+ this.ingredientId = ingredientId;
+ }
}
diff --git a/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java b/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java
index 1802525..8eab4e6 100644
--- a/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java
+++ b/commons/src/main/java/commons/ws/messages/DeleteRecipeMessage.java
@@ -8,6 +8,8 @@ package commons.ws.messages;
public class DeleteRecipeMessage implements Message {
private Long recipeId;
+ public DeleteRecipeMessage() {} // for jackson
+
public DeleteRecipeMessage(Long recipeId) {
this.recipeId = recipeId;
}
@@ -25,4 +27,9 @@ public class DeleteRecipeMessage implements Message {
public Long getRecipeId() {
return recipeId;
}
+
+ // for jackson
+ public void setRecipeId(Long recipeId) {
+ this.recipeId = recipeId;
+ }
}
diff --git a/commons/src/main/java/commons/ws/messages/Message.java b/commons/src/main/java/commons/ws/messages/Message.java
index 9e20df0..5c05d7f 100644
--- a/commons/src/main/java/commons/ws/messages/Message.java
+++ b/commons/src/main/java/commons/ws/messages/Message.java
@@ -1,5 +1,21 @@
package commons.ws.messages;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ property = "type"
+)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = CreateRecipeMessage.class, name = "RECIPE_CREATE"),
+ @JsonSubTypes.Type(value = UpdateRecipeMessage.class, name = "RECIPE_UPDATE"),
+ @JsonSubTypes.Type(value = DeleteRecipeMessage.class, name = "RECIPE_DELETE"),
+ @JsonSubTypes.Type(value = CreateIngredientMessage.class, name = "INGREDIENT_CREATE"),
+ @JsonSubTypes.Type(value = UpdateIngredientMessage.class, name = "INGREDIENT_UPDATE"),
+ @JsonSubTypes.Type(value = DeleteIngredientMessage.class, name = "INGREDIENT_DELETE")
+})
public interface Message {
public enum Type {
/**
diff --git a/commons/src/main/java/commons/ws/messages/UpdateIngredientMessage.java b/commons/src/main/java/commons/ws/messages/UpdateIngredientMessage.java
index e997873..e45e5a5 100644
--- a/commons/src/main/java/commons/ws/messages/UpdateIngredientMessage.java
+++ b/commons/src/main/java/commons/ws/messages/UpdateIngredientMessage.java
@@ -10,6 +10,8 @@ import commons.Ingredient;
public class UpdateIngredientMessage implements Message {
private Ingredient ingredient;
+ public UpdateIngredientMessage() {} // for jackson
+
public UpdateIngredientMessage(Ingredient ingredient) {
this.ingredient = ingredient;
}
@@ -27,4 +29,9 @@ public class UpdateIngredientMessage implements Message {
public Ingredient getIngredient() {
return ingredient;
}
+
+ // for jackson
+ public void setIngredient(Ingredient ingredient) {
+ this.ingredient = ingredient;
+ }
}
diff --git a/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java b/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java
index c623b75..72c9fd6 100644
--- a/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java
+++ b/commons/src/main/java/commons/ws/messages/UpdateRecipeMessage.java
@@ -10,6 +10,8 @@ import commons.Recipe;
public class UpdateRecipeMessage implements Message {
private Recipe recipe;
+ public UpdateRecipeMessage() {} // for jackson
+
public UpdateRecipeMessage(Recipe recipe) {
this.recipe = recipe;
}
@@ -27,4 +29,9 @@ public class UpdateRecipeMessage implements Message {
public Recipe getRecipe() {
return recipe;
}
+
+ // for jackson
+ public void setRecipe(Recipe recipe) {
+ this.recipe = recipe;
+ }
}