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. diff --git a/client/pom.xml b/client/pom.xml index b9eb186..ebbef30 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -21,7 +21,13 @@ commons 0.0.1-SNAPSHOT - + + + org.slf4j + slf4j-api + 2.0.17 + compile + com.fasterxml.jackson.core jackson-databind diff --git a/client/src/main/java/client/Ingredient/IngredientController.java b/client/src/main/java/client/Ingredient/IngredientController.java index 429fecd..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 - private 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/main/java/client/MyModule.java b/client/src/main/java/client/MyModule.java index 4d027ca..14eaa3e 100644 --- a/client/src/main/java/client/MyModule.java +++ b/client/src/main/java/client/MyModule.java @@ -21,6 +21,11 @@ import client.scenes.nutrition.NutritionDetailsCtrl; 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; import client.utils.ConfigService; import client.utils.LocaleManager; import client.utils.server.ServerUtils; @@ -57,6 +62,10 @@ 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); binder.bind(new TypeLiteral>() {}).toInstance( new WebSocketDataService<>() ); 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/exception/DuplicateIngredientException.java b/client/src/main/java/client/exception/DuplicateIngredientException.java new file mode 100644 index 0000000..af04346 --- /dev/null +++ b/client/src/main/java/client/exception/DuplicateIngredientException.java @@ -0,0 +1,20 @@ +package client.exception; + +public class DuplicateIngredientException extends Exception{ + private final String ingredientName; + + public DuplicateIngredientException(String ingredientName){ + super("An ingredient with name " + ingredientName + " already exists, please provide a different name"); + this.ingredientName = ingredientName; + } + + public DuplicateIngredientException(String ingredientName, String message){ + super(message); + this.ingredientName = ingredientName; + } + + public String getIngredientName() { + return ingredientName; + } + +} diff --git a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java index 3242d21..181279b 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -2,17 +2,17 @@ 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.UI; import client.exception.InvalidModificationException; import client.scenes.nutrition.NutritionViewCtrl; import client.scenes.recipe.RecipeDetailCtrl; +import client.scenes.shopping.ShoppingListCtrl; import client.utils.Config; import client.utils.ConfigService; import client.utils.DefaultValueFactory; @@ -21,7 +21,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; @@ -32,15 +31,20 @@ 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.Scene; import javafx.scene.control.Alert; 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 javafx.stage.Stage; import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; @@ -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) { @@ -452,114 +463,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(name -> { - // Create a new Ingredient object - Ingredient newIngredient = new Ingredient(); - newIngredient.setName(name); - - // Add the new ingredient to the ListView - ingredientListView.getItems().add(newIngredient); - }); - } - - // 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 { @@ -611,6 +514,14 @@ public class FoodpalApplicationCtrl implements LocaleAware { updatedBadgeTimer.playFromStart(); } + public void openShoppingListWindow() throws IOException { + var root = UI.getFXML().load(ShoppingListCtrl.class, "client", "scenes", "shopping", "ShoppingList.fxml"); + Stage stage = new Stage(); + stage.setTitle(this.getLocaleString("menu.shopping.title")); + stage.setScene(new Scene(root.getValue())); + stage.show(); + } + } diff --git a/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java b/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java index 329eb56..c06a04a 100644 --- a/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java +++ b/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java @@ -1,5 +1,6 @@ package client.scenes.Ingredient; +import client.exception.DuplicateIngredientException; import client.scenes.nutrition.NutritionDetailsCtrl; import client.utils.LocaleAware; import client.utils.LocaleManager; @@ -66,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) { @@ -113,6 +114,8 @@ public class IngredientListCtrl implements LocaleAware { refresh(); // reload list from server } catch (IOException | InterruptedException e) { showError("Failed to create ingredient: " + e.getMessage()); + } catch (DuplicateIngredientException e) { + throw new RuntimeException(e); } } diff --git a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java index 0dc719a..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", "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/SearchBarCtrl.java b/client/src/main/java/client/scenes/SearchBarCtrl.java index 065455c..c37f188 100644 --- a/client/src/main/java/client/scenes/SearchBarCtrl.java +++ b/client/src/main/java/client/scenes/SearchBarCtrl.java @@ -10,11 +10,15 @@ 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; +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 +103,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); } }; @@ -146,8 +154,61 @@ 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(); }); } + 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/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/scenes/nutrition/NutritionPieChartCtrl.java b/client/src/main/java/client/scenes/nutrition/NutritionPieChartCtrl.java new file mode 100644 index 0000000..a90a677 --- /dev/null +++ b/client/src/main/java/client/scenes/nutrition/NutritionPieChartCtrl.java @@ -0,0 +1,234 @@ +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( + this.getLocaleString("menu.nutrition.protein"), + info.protein() + ), + new PieChart.Data( + this.getLocaleString("menu.nutrition.fat"), + info.fat() + ), + new PieChart.Data( + this.getLocaleString("menu.nutrition.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() { + List data = this.pieChart.getData(); + if (data.isEmpty()) return; + final int EXPECTED_DATA_SIZE = 3; + if (data.size() != EXPECTED_DATA_SIZE) return; + + data.get(0).setName(this.getLocaleString("menu.nutrition.protein")); + data.get(1).setName(this.getLocaleString("menu.nutrition.fat")); + final int TWO = 2; + data.get(TWO).setName(this.getLocaleString("menu.nutrition.carbs")); + } + + @Override + public LocaleManager getLocaleManager() { + return this.localeManager; + } + + /** + * Refresh the data in this pie chart. + */ + @SuppressWarnings("checkstyle:MagicNumber") + public 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); + if (this.pieChart.getData().isEmpty()) { + this.pieChart.getData().setAll( + this.getPieChartData() + ); + } else { + List fresh = this.getPieChartData(); + List data = this.pieChart.getData(); + + if (fresh.isEmpty()) { + data.clear(); + return; + } + + data.get(0).setPieValue(fresh.get(0).getPieValue()); + data.get(1).setPieValue(fresh.get(1).getPieValue()); + final int TWO = 2; + data.get(TWO).setPieValue(fresh.get(TWO).getPieValue()); + } + } + + 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/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); } diff --git a/client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java b/client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java index de27245..091befd 100644 --- a/client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java +++ b/client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java @@ -1,6 +1,7 @@ package client.scenes.recipe; import client.utils.server.ServerUtils; +import client.exception.DuplicateIngredientException; import commons.Ingredient; import jakarta.inject.Inject; import javafx.fxml.FXML; @@ -67,8 +68,15 @@ public class IngredientsPopupCtrl { server.createIngredient(name); // calls POST /api/ingredients refresh(); // reload list from server } catch (IOException | InterruptedException e) { + showError("Failed to create ingredient: " + e.getMessage()); + } catch (DuplicateIngredientException e) { + showError("An ingredient with the name " + name + " already exists." + + " Please provide a different name."); //checks if error received has the DUPLICATE string and creates a showError instance with an appropriate error message and description + } + + } diff --git a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java index cdf5724..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,8 @@ 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; import client.utils.LocaleAware; @@ -10,6 +12,7 @@ import client.utils.PrintExportService; import client.utils.server.ServerUtils; import client.utils.WebSocketDataService; import com.google.inject.Inject; +import commons.FormalIngredient; import commons.Recipe; import java.io.File; @@ -20,7 +23,10 @@ import java.util.function.BiConsumer; 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; @@ -34,6 +40,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. @@ -46,9 +54,12 @@ public class RecipeDetailCtrl implements LocaleAware { private final FoodpalApplicationCtrl appCtrl; private final ConfigService configService; private final WebSocketDataService webSocketDataService; + private final ShoppingListService shoppingListService; public Spinner scaleSpinner; public Label inferredKcalLabel; + public Spinner servingsSpinner; + public Label inferredServeSizeLabel; @FXML private IngredientListCtrl ingredientListController; @@ -65,12 +76,14 @@ public class RecipeDetailCtrl implements LocaleAware { ServerUtils server, FoodpalApplicationCtrl appCtrl, ConfigService configService, + ShoppingListService listService, WebSocketDataService webSocketDataService) { this.localeManager = localeManager; this.server = server; this.appCtrl = appCtrl; this.configService = configService; this.webSocketDataService = webSocketDataService; + this.shoppingListService = listService; } @FXML @@ -94,6 +107,9 @@ public class RecipeDetailCtrl implements LocaleAware { @FXML private ComboBox langSelector; + @FXML + private NutritionPieChartCtrl pieChartController; + private ListView getParentRecipeList() { return this.appCtrl.recipeList; } @@ -124,7 +140,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 { @@ -157,24 +172,31 @@ public class RecipeDetailCtrl implements LocaleAware { // If there is a scale // Prevents issues from first startup - if (scaleSpinner.getValue() != null) { + if (scaleSpinner.getValue() != null && servingsSpinner.getValue() != null) { Double scale = scaleSpinner.getValue(); // see impl. creates a scaled context for the recipe such that its non-scaled value is kept as a reference. 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( + () -> String.format("Inferred size per serving: %.1f g", recipeView.servingSizeProperty().get()), + recipeView.servingSizeProperty())); // 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); } /** @@ -185,7 +207,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 -> { @@ -325,6 +347,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. @@ -402,6 +425,47 @@ public class RecipeDetailCtrl implements LocaleAware { // triggers a UI update each time the spinner changes to a different value. setCurrentlyViewedRecipe(recipe); }); - langSelector.getItems().addAll("en", "nl", "pl", "tok"); + servingsSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1)); + servingsSpinner.setEditable(true); + servingsSpinner.valueProperty().addListener((observable, oldValue, newValue) -> { + if (newValue == null) { + return; + } + setCurrentlyViewedRecipe(recipe); + }); + langSelector.getItems().addAll(Config.languages); + } + + @FXML + public void handleAddAllToShoppingList(ActionEvent actionEvent) { + 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/recipe/ScalableRecipeView.java b/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java index aa80a97..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,23 +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/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/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); + } + +} diff --git a/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java b/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java new file mode 100644 index 0000000..1367936 --- /dev/null +++ b/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java @@ -0,0 +1,119 @@ +package client.scenes.shopping; + +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; +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 { + ShoppingListService shopping; + + LocaleManager localeManager; + + @FXML + private ListView>> shoppingListView; + @Inject + public ShoppingListCtrl( + ShoppingListService shopping, + LocaleManager localeManager + ) { + this.shopping = shopping; + this.localeManager = localeManager; + } + + @Override + public void updateText() { + + } + + @Override + public LocaleManager getLocaleManager() { + return this.localeManager; + } + + public void initializeComponents() { + this.shoppingListView.setEditable(true); + this.shoppingListView.setCellFactory(l -> new ShoppingListCell()); + this.shoppingListView.setItems(this.shopping.getViewModel().getListItems()); + } + + 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); + + }); + stage.setScene(new Scene(root.getValue())); + stage.setTitle("My modal window"); + stage.initModality(Modality.WINDOW_MODAL); + stage.initOwner( + ((Node)actionEvent.getSource()).getScene().getWindow() ); + stage.showAndWait(); + } + + public void handleRemoveItem(ActionEvent actionEvent) { + var x = this.shoppingListView.getSelectionModel().getSelectedItem(); + 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/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/java/client/service/NonFunctionalShoppingListService.java b/client/src/main/java/client/service/NonFunctionalShoppingListService.java new file mode 100644 index 0000000..d85b320 --- /dev/null +++ b/client/src/main/java/client/service/NonFunctionalShoppingListService.java @@ -0,0 +1,62 @@ +package client.service; + +import com.google.inject.Inject; +import commons.FormalIngredient; +import commons.Recipe; +import javafx.util.Pair; +import org.apache.commons.lang3.NotImplementedException; + +import java.util.List; +import java.util.Optional; + +public class NonFunctionalShoppingListService extends ShoppingListService { + @Inject + public NonFunctionalShoppingListService(ShoppingListViewModel viewModel) { + super(viewModel); + } + + @Override + public void putIngredient(FormalIngredient ingredient) { + throw new NotImplementedException(); + } + + @Override + public void putIngredient(FormalIngredient ingredient, Recipe recipe) { + throw new NotImplementedException(); + } + + @Override + public void putIngredient(FormalIngredient ingredient, String recipeName) { + throw new NotImplementedException(); + } + + @Override + public void putArbitraryItem(String name) { + throw new NotImplementedException(); + } + + @Override + public FormalIngredient purgeIngredient(Long id) { + throw new NotImplementedException(); + } + + @Override + public FormalIngredient purgeIngredient(String ingredientName) { + throw new NotImplementedException(); + } + + @Override + public void reset() { + throw new NotImplementedException(); + } + + @Override + public List>> getItems() { + throw new NotImplementedException(); + } + + @Override + public String makePrintable() { + throw new NotImplementedException(); + } +} diff --git a/client/src/main/java/client/service/ShoppingListItem.java b/client/src/main/java/client/service/ShoppingListItem.java new file mode 100644 index 0000000..91dd98a --- /dev/null +++ b/client/src/main/java/client/service/ShoppingListItem.java @@ -0,0 +1,6 @@ +package client.service; + +import commons.FormalIngredient; +public class ShoppingListItem { + private FormalIngredient i; +} diff --git a/client/src/main/java/client/service/ShoppingListService.java b/client/src/main/java/client/service/ShoppingListService.java new file mode 100644 index 0000000..04ef26b --- /dev/null +++ b/client/src/main/java/client/service/ShoppingListService.java @@ -0,0 +1,38 @@ +package client.service; + +import com.google.inject.Inject; +import commons.FormalIngredient; +import commons.Recipe; +import javafx.util.Pair; + +import java.util.List; +import java.util.Optional; + +public abstract class ShoppingListService { + private ShoppingListViewModel viewModel; + @Inject + public ShoppingListService(ShoppingListViewModel viewModel) { + this.viewModel = viewModel; + } + + public ShoppingListViewModel getViewModel() { + return viewModel; + } + + public void setViewModel(ShoppingListViewModel viewModel) { + this.viewModel = viewModel; + } + + public abstract void putIngredient(FormalIngredient ingredient); + public abstract void putIngredient(FormalIngredient ingredient, Recipe recipe); + public abstract void putIngredient(FormalIngredient ingredient, String recipeName); + public abstract void putArbitraryItem(String name); + + public abstract FormalIngredient purgeIngredient(Long id); + public abstract FormalIngredient purgeIngredient(String ingredientName); + + public abstract void reset(); + + public abstract List>> getItems(); + public abstract String makePrintable(); +} diff --git a/client/src/main/java/client/service/ShoppingListServiceImpl.java b/client/src/main/java/client/service/ShoppingListServiceImpl.java new file mode 100644 index 0000000..21c16f2 --- /dev/null +++ b/client/src/main/java/client/service/ShoppingListServiceImpl.java @@ -0,0 +1,124 @@ +package client.service; + +import com.google.inject.Inject; +import commons.FormalIngredient; +import commons.Recipe; +import javafx.util.Pair; + +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; + +public class ShoppingListServiceImpl extends ShoppingListService { + private final Logger logger = Logger.getLogger(ShoppingListServiceImpl.class.getName()); + @Inject + public ShoppingListServiceImpl( + ShoppingListViewModel model + ) { + super(model); + } + + @Override + public void putIngredient(FormalIngredient ingredient) { + getViewModel().getListItems().add(new Pair<>(ingredient, Optional.empty())); + } + + @Override + public void putIngredient(FormalIngredient ingredient, Recipe recipe) { + Pair> val = new Pair<>(ingredient, Optional.of(recipe.getName())); + logger.info("putting ingredients into shopping list: " + val); + getViewModel().getListItems().add(val); + } + + @Override + public void putIngredient(FormalIngredient ingredient, String recipeName) { + getViewModel().getListItems().add(new Pair<>(ingredient, Optional.of(recipeName))); + } + + @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) { + 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; + } + + @Override + public void reset() { + getViewModel().getListItems().clear(); + } + + @Override + public List>> getItems() { + return getViewModel().getListItems(); + } + + @Override + public String makePrintable() { + 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/java/client/service/ShoppingListViewModel.java b/client/src/main/java/client/service/ShoppingListViewModel.java new file mode 100644 index 0000000..aaaa237 --- /dev/null +++ b/client/src/main/java/client/service/ShoppingListViewModel.java @@ -0,0 +1,26 @@ +package client.service; + +import commons.FormalIngredient; +import javafx.beans.property.ListProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.util.Pair; +import org.apache.commons.lang3.NotImplementedException; + +import java.util.Optional; + +public class ShoppingListViewModel { + /** + * The formal ingredient provides the ingredient and its amount, + * and the string (optional) describes the recipe where it came from. + */ + private final ListProperty>> listItems = new SimpleListProperty<>(FXCollections.observableArrayList()); + public void addArbitrary() { + throw new NotImplementedException(); + } + + public ObservableList>> getListItems() { + return listItems.get(); + } +} 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"; 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"); diff --git a/client/src/main/java/client/utils/server/ServerUtils.java b/client/src/main/java/client/utils/server/ServerUtils.java index 5330fdd..8bd7f3b 100644 --- a/client/src/main/java/client/utils/server/ServerUtils.java +++ b/client/src/main/java/client/utils/server/ServerUtils.java @@ -1,19 +1,18 @@ package client.utils.server; import client.utils.ConfigService; +import client.exception.DuplicateIngredientException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; 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 +170,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; } @@ -270,7 +268,7 @@ public class ServerUtils { //creates new ingredients in the ingredient list - public Ingredient createIngredient(String name) throws IOException, InterruptedException { + public Ingredient createIngredient(String name) throws IOException, InterruptedException, DuplicateIngredientException { Ingredient ingredient = new Ingredient(name, 0.0, 0.0, 0.0); String json = objectMapper.writeValueAsString(ingredient); @@ -278,6 +276,11 @@ public class ServerUtils { .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + final int DUPLICATE_STATUS_CODE = 409; + if (response.statusCode() == DUPLICATE_STATUS_CODE) { + throw new DuplicateIngredientException(name); + } + if (response.statusCode() != statusOK) { throw new IOException("Failed to create ingredient. Server responds with: " + response.body()); } diff --git a/client/src/main/resources/client/scenes/FoodpalApplication.fxml b/client/src/main/resources/client/scenes/FoodpalApplication.fxml index c777543..adfffba 100644 --- a/client/src/main/resources/client/scenes/FoodpalApplication.fxml +++ b/client/src/main/resources/client/scenes/FoodpalApplication.fxml @@ -70,6 +70,9 @@ + - + + + + + + + @@ -37,5 +42,11 @@ -