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 98d878a..0afc5f9 100644 --- a/client/src/main/java/client/MyModule.java +++ b/client/src/main/java/client/MyModule.java @@ -19,13 +19,18 @@ import client.scenes.FoodpalApplicationCtrl; import client.scenes.SearchBarCtrl; 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 @@ -36,5 +41,10 @@ public class MyModule implements Module { binder.bind(RecipeStepListCtrl.class).in(Scopes.SINGLETON); binder.bind(SearchBarCtrl.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 2c796cf..4413e5c 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -9,14 +9,20 @@ import java.util.Locale; 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.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Alert; @@ -32,6 +38,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; @@ -57,7 +64,7 @@ public class FoodpalApplicationCtrl implements LocaleAware { public Label recipesLabel; @FXML - private ListView recipeList; + public ListView recipeList; @FXML private Button addRecipeButton; @@ -68,6 +75,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 @@ -82,16 +96,23 @@ public class FoodpalApplicationCtrl implements LocaleAware { @Inject public FoodpalApplicationCtrl( ServerUtils server, + WebSocketUtils webSocketUtils, LocaleManager localeManager, IngredientListCtrl ingredientListCtrl, RecipeStepListCtrl stepListCtrl, - SearchBarCtrl searchBarCtrl + SearchBarCtrl searchBarCtrl, + ConfigService configService ) { this.server = server; + this.webSocketUtils = webSocketUtils; this.localeManager = localeManager; this.ingredientListCtrl = ingredientListCtrl; this.stepListCtrl = stepListCtrl; this.searchBarCtrl = searchBarCtrl; + + this.configService = configService; + + initializeWebSocket(); } private void initializeSearchBar() { @@ -119,8 +140,31 @@ 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. + }); + }); + } + @Override public void initializeComponents() { + config = configService.getConfig(); // TODO Reduce code duplication?? // Initialize callback for ingredient list updates this.ingredientListCtrl.setUpdateCallback(newList -> { @@ -159,10 +203,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 @@ -176,7 +222,9 @@ public class FoodpalApplicationCtrl implements LocaleAware { this.initializeSearchBar(); refresh(); + updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem()); } + private void showName(String name) { final int NAME_FONT_SIZE = 20; editableTitleArea.getChildren().clear(); @@ -184,6 +232,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")); @@ -237,11 +286,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: @@ -314,7 +365,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!"); @@ -325,7 +376,6 @@ public class FoodpalApplicationCtrl implements LocaleAware { edit.requestFocus(); } - // Language buttons @FXML private void switchLocale(ActionEvent event) { @@ -353,6 +403,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 0087ac4..049b8cf 100644 --- a/client/src/main/resources/client/scenes/FoodpalApplication.fxml +++ b/client/src/main/resources/client/scenes/FoodpalApplication.fxml @@ -115,6 +115,7 @@