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..68ef84f 100644 --- a/client/src/main/java/client/MyModule.java +++ b/client/src/main/java/client/MyModule.java @@ -18,13 +18,18 @@ package client; import client.scenes.FoodpalApplicationCtrl; import client.scenes.recipe.IngredientListCtrl; import client.scenes.recipe.RecipeStepListCtrl; +import client.utils.ConfigService; 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; import client.scenes.MainCtrl; +import java.nio.file.Path; + public class MyModule implements Module { @Override @@ -33,6 +38,12 @@ 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); + + binder.bind(ConfigService.class).toInstance(new ConfigService(Path.of("config.json"))); + } } \ 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 16acd3a..8fdc771 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -9,17 +9,22 @@ import client.exception.UpdateException; import client.scenes.recipe.IngredientListCtrl; import client.scenes.recipe.RecipeStepListCtrl; +import client.utils.Config; +import client.utils.ConfigService; 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.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; @@ -31,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; @@ -41,10 +47,9 @@ public class FoodpalApplicationCtrl implements LocaleAware { // everything in the left lane @FXML public Label recipesLabel; - public ComboBox langSelectMenu; @FXML - private ListView recipeList; + public ListView recipeList; @FXML private Button addRecipeButton; @@ -55,6 +60,13 @@ public class FoodpalApplicationCtrl implements LocaleAware { @FXML public Button cloneRecipeButton; + @FXML + private Button favouriteButton; + + private Config config; + private final ConfigService configService; + + // === CENTER: RECIPE DETAILS === @FXML @@ -69,14 +81,20 @@ public class FoodpalApplicationCtrl implements LocaleAware { @Inject public FoodpalApplicationCtrl( ServerUtils server, + WebSocketUtils webSocketUtils, LocaleManager localeManager, IngredientListCtrl ingredientListCtrl, - RecipeStepListCtrl stepListCtrl + RecipeStepListCtrl stepListCtrl, + ConfigService configService ) { this.server = server; + this.webSocketUtils = webSocketUtils; this.localeManager = localeManager; this.ingredientListCtrl = ingredientListCtrl; this.stepListCtrl = stepListCtrl; + this.configService = configService; + + initializeWebSocket(); } private void initRecipeList() { // Show recipe name in the list @@ -87,10 +105,12 @@ public class FoodpalApplicationCtrl implements LocaleAware { setText(empty || item == null ? "" : item.getName()); } }); - // When your selection changes, update details in the panel recipeList.getSelectionModel().selectedItemProperty().addListener( - (obs, oldRecipe, newRecipe) -> showRecipeDetails(newRecipe) + (obs, oldRecipe, newRecipe) -> { + showRecipeDetails(newRecipe); + updateFavouriteButton(newRecipe); + } ); // Double-click to go to detail screen @@ -101,6 +121,27 @@ public class FoodpalApplicationCtrl implements LocaleAware { } }); } + 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. + }); + }); + } private void initStepsIngredientsList() { // Initialize callback for ingredient list updates this.ingredientListCtrl.setUpdateCallback(newList -> { @@ -134,12 +175,14 @@ public class FoodpalApplicationCtrl implements LocaleAware { } @Override public void initializeComponents() { - // TODO Reduce code duplication?? - + config = configService.getConfig(); initStepsIngredientsList(); initRecipeList(); + refresh(); + updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem()); } + private void showName(String name) { final int NAME_FONT_SIZE = 20; editableTitleArea.getChildren().clear(); @@ -147,6 +190,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")); @@ -158,13 +202,6 @@ public class FoodpalApplicationCtrl implements LocaleAware { printRecipeButton.setText(getLocaleString("menu.button.print")); recipesLabel.setText(getLocaleString("menu.label.recipes")); - - // TODO propagate ResourceBundle lang changes to nested controller - // ingredientsLabel.setText(getLocaleString("menu.label.ingredients")); - // preparationLabel.setText(getLocaleString("menu.label.preparation")); - - // addIngredientButton.setText(getLocaleString("menu.button.add.ingredient")); - // addPreparationStepButton.setText(getLocaleString("menu.button.add.step")); } @Override @@ -200,11 +237,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: @@ -277,7 +316,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!"); @@ -287,7 +326,6 @@ public class FoodpalApplicationCtrl implements LocaleAware { editableTitleArea.getChildren().add(edit); edit.requestFocus(); } - @FXML private void makePrintable() { System.out.println("Recipe printed"); @@ -307,6 +345,39 @@ public class FoodpalApplicationCtrl implements LocaleAware { recipeList.getSelectionModel().select(cloned); } + private void updateFavouriteButton(Recipe recipe) { + if (recipe == null) { + favouriteButton.setDisable(true); + favouriteButton.setText("☆"); + return; + } + + favouriteButton.setDisable(false); + favouriteButton.setText( + config.isFavourite(recipe.getId()) ? "★" : "☆" + ); + } + + @FXML + private void toggleFavourite() { + Recipe selected = recipeList.getSelectionModel().getSelectedItem(); + if (selected == null) { + return; + } + + long id = selected.getId(); + + if (config.isFavourite(id)) { + config.removeFavourite(id); + } else { + config.addFavourite(id); + } + + configService.save(); + updateFavouriteButton(selected); + } + + } diff --git a/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java b/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java new file mode 100644 index 0000000..9a60b4a --- /dev/null +++ b/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java @@ -0,0 +1,38 @@ +package client.scenes.nutrition; + +import client.utils.LocaleAware; +import client.utils.LocaleManager; +import com.google.inject.Inject; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; + +public class NutritionDetailsCtrl implements LocaleAware { + private final LocaleManager manager; + + public Label ingredientName; + public Label fatInputLabel; + public Label proteinInputLabel; + public Label carbInputLabel; + public Label estimatedKcalLabel; + public Label usageLabel; + + public TextField fatInputElement; + public TextField proteinInputElement; + public TextField carbInputElement; + + @Inject + public NutritionDetailsCtrl( + LocaleManager manager + ) { + this.manager = manager; + } + @Override + public void updateText() { + + } + + @Override + public LocaleManager getLocaleManager() { + return manager; + } +} diff --git a/client/src/main/java/client/scenes/nutrition/NutritionViewCtrl.java b/client/src/main/java/client/scenes/nutrition/NutritionViewCtrl.java new file mode 100644 index 0000000..493db8e --- /dev/null +++ b/client/src/main/java/client/scenes/nutrition/NutritionViewCtrl.java @@ -0,0 +1,68 @@ +package client.scenes.nutrition; + +import client.scenes.FoodpalApplicationCtrl; +import com.google.inject.Inject; +import commons.Recipe; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.control.ListView; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class NutritionViewCtrl { + private ObservableList recipes; + private HashMap ingredientStats; + public ListView nutritionIngredientsView; + private final NutritionDetailsCtrl nutritionDetailsCtrl; + + // TODO into Ingredient class definition + + /** + * Comedically verbose function to count unique appearances of an ingredient by name in each recipe. + * For each recipe: + * 1. Collect unique ingredients that appeared in that recipe. + * 2. For each unique ingredient in said recipe: + * 1. Initialize the appearance for that ingredient to 0. + * 2. For each recipe in list: + * 1. If the name of the ingredient exists in the recipe list, increment the statistic by 1. + * 2. Else maintain the same value for that statistic. + * @param recipeList + */ + private void updateIngredientStats( + List recipeList + ) { + recipeList.forEach(recipe -> { + Set uniqueIngredients = new HashSet<>(recipe.getIngredients()); + nutritionIngredientsView.getItems().setAll(uniqueIngredients); + uniqueIngredients.forEach(ingredient -> { + ingredientStats.put(ingredient, 0); + recipeList.forEach(r -> + ingredientStats.put( + ingredient, + ingredientStats.get(ingredient) + ( + (r.getIngredients().contains(ingredient)) + ? 1 : 0 + ) + ) + ); + }); + }); + } + @Inject + public NutritionViewCtrl( + FoodpalApplicationCtrl foodpalApplicationCtrl, + NutritionDetailsCtrl nutritionDetailsCtrl + ) { + this.recipes = foodpalApplicationCtrl.recipeList.getItems(); + this.recipes.addListener((ListChangeListener) _ -> { + updateIngredientStats(this.recipes); + }); + this.nutritionDetailsCtrl = nutritionDetailsCtrl; + this.nutritionIngredientsView.selectionModelProperty().addListener((observable, oldValue, newValue) -> { + + }); + } +} diff --git a/client/src/main/java/client/utils/Config.java b/client/src/main/java/client/utils/Config.java index c94dd4c..97eaca1 100644 --- a/client/src/main/java/client/utils/Config.java +++ b/client/src/main/java/client/utils/Config.java @@ -12,7 +12,8 @@ public class Config { private List favourites = new ArrayList<>(); private List shoppingList = new ArrayList<>(); - public Config(){} + public Config() { + } public String getLanguage() { return language; @@ -22,10 +23,6 @@ public class Config { return shoppingList; } - public List getFavourites() { - return favourites; - } - public String getServerUrl() { return serverUrl; } @@ -45,4 +42,29 @@ public class Config { public void setShoppingList(List shoppingList) { this.shoppingList = shoppingList; } + + // favourite helper + + public List getFavourites() { + if (favourites == null) { + favourites = new ArrayList<>(); + } + return favourites; + } +// to avoid null pointers. + + public boolean isFavourite(long recipeId) { + return getFavourites().contains(recipeId); + } + + public void addFavourite(long recipeId) { + if (!getFavourites().contains(recipeId)) { + getFavourites().add(recipeId); + } + } + + public void removeFavourite(long recipeId) { + getFavourites().remove(recipeId); + } } + diff --git a/client/src/main/java/client/utils/ServerUtilsExample.java b/client/src/main/java/client/utils/ServerUtilsExample.java deleted file mode 100644 index bc4a187..0000000 --- a/client/src/main/java/client/utils/ServerUtilsExample.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2021 Delft University of Technology - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package client.utils; - -import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; - -import java.net.ConnectException; -import java.util.List; - -import jakarta.ws.rs.core.GenericType; -import org.glassfish.jersey.client.ClientConfig; - -import commons.Quote; -import jakarta.ws.rs.ProcessingException; -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.client.Entity; - -public class ServerUtilsExample { - - private static final String SERVER = "http://localhost:8080/"; - -// public void getQuotesTheHardWay() throws IOException, URISyntaxException { -// var url = new URI("http://localhost:8080/api/quotes").toURL(); -// var is = url.openConnection().getInputStream(); -// var br = new BufferedReader(new InputStreamReader(is)); -// String line; -// while ((line = br.readLine()) != null) { -// System.out.println(line); -// } -// } - - public List getQuotes() { - return ClientBuilder.newClient(new ClientConfig()) // - .target(SERVER).path("api/quotes") // - .request(APPLICATION_JSON) // - .get(new GenericType>() {}); - } - - public Quote addQuote(Quote quote) { - return ClientBuilder.newClient(new ClientConfig()) // - .target(SERVER).path("api/quotes") // - .request(APPLICATION_JSON) // - .post(Entity.entity(quote, APPLICATION_JSON), Quote.class); - } - - public boolean isServerAvailable() { - try { - ClientBuilder.newClient(new ClientConfig()) // - .target(SERVER) // - .request(APPLICATION_JSON) // - .get(); - } catch (ProcessingException e) { - if (e.getCause() instanceof ConnectException) { - return false; - } - } - return true; - } -} \ No newline at end of file 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/client/src/main/resources/client/scenes/FoodpalApplication.fxml b/client/src/main/resources/client/scenes/FoodpalApplication.fxml index b9edd58..63779d6 100644 --- a/client/src/main/resources/client/scenes/FoodpalApplication.fxml +++ b/client/src/main/resources/client/scenes/FoodpalApplication.fxml @@ -69,6 +69,7 @@