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 e7cb0df..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,14 +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;
@@ -65,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;
@@ -84,7 +88,8 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@FXML
private ToggleButton favouritesOnlyToggle;
- private List allRecipes = new ArrayList<>();
+ @FXML
+ private Button manageIngredientsButton;
@FXML
private Label updatedBadge;
@@ -200,6 +205,12 @@ public class FoodpalApplicationCtrl implements LocaleAware {
} //gives star in front fav items
boolean fav = config.isFavourite(item.getId());
setText((fav ? "★ " : "") + item.getName());
+
+ if(fav){
+ setTextFill(Color.BLUE);
+ }else{
+ setTextFill(Color.BLACK);
+ }
}
});
// When your selection changes, update details in the panel
@@ -277,6 +288,13 @@ public class FoodpalApplicationCtrl implements LocaleAware {
openSelectedRecipe();
}
});
+ recipeList.getItems().addListener((ListChangeListener.Change extends Recipe> c) -> {
+ favouriteRecipeList.set(
+ FXCollections.observableList(
+ recipeList.getItems().stream().filter(r -> config.isFavourite(r.getId())).toList()
+ ));
+ System.out.println(favouriteRecipeList);
+ });
this.initializeSearchBar();
refresh();
@@ -290,6 +308,9 @@ public class FoodpalApplicationCtrl implements LocaleAware {
removeRecipeButton.setText(getLocaleString("menu.button.remove.recipe"));
cloneRecipeButton.setText(getLocaleString("menu.button.clone"));
recipesLabel.setText(getLocaleString("menu.label.recipes"));
+
+ favouritesOnlyToggle.setText(getLocaleString("menu.button.favourites"));
+ manageIngredientsButton.setText(getLocaleString("menu.button.ingredients"));
}
@Override
@@ -312,8 +333,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
logger.severe(msg);
printError(msg);
}
-
- allRecipes = new ArrayList<>(recipes);
+ recipeList.getItems().setAll(recipes);
applyRecipeFilterAndKeepSelection();
showUpdatedBadge();
@@ -409,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) {
@@ -439,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 {
@@ -558,7 +474,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
var root = pair.getValue();
var stage = new javafx.stage.Stage();
- stage.setTitle("Nutrition values view");
+ stage.setTitle(getLocaleString("menu.ingredients.title"));
stage.initModality(javafx.stage.Modality.APPLICATION_MODAL);
stage.setScene(new javafx.scene.Scene(root));
stage.showAndWait();
@@ -598,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 dfcd7be..c06a04a 100644
--- a/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java
+++ b/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java
@@ -1,11 +1,17 @@
package client.scenes.Ingredient;
+import client.exception.DuplicateIngredientException;
import client.scenes.nutrition.NutritionDetailsCtrl;
+import client.utils.LocaleAware;
+import client.utils.LocaleManager;
import client.utils.server.ServerUtils;
import commons.Ingredient;
import jakarta.inject.Inject;
import javafx.fxml.FXML;
+
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;
@@ -19,10 +25,18 @@ import java.util.logging.Logger;
//TODO and check for capital letter milk and MILK are seen as different
-public class IngredientListCtrl {
-
+public class IngredientListCtrl implements LocaleAware {
private final ServerUtils server;
+ private final LocaleManager localeManager;
private final Logger logger = Logger.getLogger(IngredientListCtrl.class.getName());
+
+ @FXML
+ public Label ingredientsLabel;
+ public Button addButton;
+ public Button refreshButton;
+ public Button deleteButton;
+ public Button closeButton;
+
@FXML
private ListView ingredientListView;
@FXML
@@ -31,14 +45,30 @@ public class IngredientListCtrl {
@Inject
public IngredientListCtrl(
ServerUtils server,
+ LocaleManager localeManager,
NutritionDetailsCtrl nutritionDetailsCtrl
) {
this.server = server;
+ this.localeManager = localeManager;
this.nutritionDetailsCtrl = nutritionDetailsCtrl;
}
- @FXML
- public void initialize() {
+ @Override
+ public void updateText() {
+ ingredientsLabel.setText(getLocaleString("menu.label.ingredients"));
+ addButton.setText(getLocaleString("menu.button.add"));
+ refreshButton.setText(getLocaleString("menu.button.refresh"));
+ deleteButton.setText(getLocaleString("menu.button.delete"));
+ closeButton.setText(getLocaleString("menu.button.close"));
+ }
+
+ @Override
+ public LocaleManager getLocaleManager() {
+ return this.localeManager;
+ }
+
+ @Override
+ public void initializeComponents() {
ingredientListView.setCellFactory(list -> new ListCell<>() {
@Override
protected void updateItem(Ingredient item, boolean empty) {
@@ -60,6 +90,7 @@ public class IngredientListCtrl {
refresh();
}
+
@FXML
private void addIngredient() {
TextInputDialog dialog = new TextInputDialog();
@@ -83,10 +114,11 @@ public class IngredientListCtrl {
refresh(); // reload list from server
} catch (IOException | InterruptedException e) {
showError("Failed to create ingredient: " + e.getMessage());
+ } catch (DuplicateIngredientException e) {
+ throw new RuntimeException(e);
}
}
-
@FXML
private void refresh() {
try {
diff --git a/client/src/main/java/client/scenes/LangSelectMenuCtrl.java b/client/src/main/java/client/scenes/LangSelectMenuCtrl.java
index 8cb6ddf..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;
@@ -41,11 +42,12 @@ public class LangSelectMenuCtrl implements LocaleAware {
String lang = langSelectMenu.getSelectionModel().getSelectedItem();
logger.info("Switching locale to " + lang);
manager.setLocale(Locale.of(lang));
+ initializeComponents();
}
@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/NutritionDetailsCtrl.java b/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java
index c7c968f..d8351f3 100644
--- a/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java
+++ b/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java
@@ -66,7 +66,7 @@ public class NutritionDetailsCtrl implements LocaleAware {
this.proteinInputElement.textProperty().bindBidirectional(vm.proteinProperty(), new NumberStringConverter());
this.carbInputElement.textProperty().bindBidirectional(vm.carbsProperty(), new NumberStringConverter());
this.estimatedKcalLabel.textProperty().bind(Bindings.createStringBinding(
- () -> String.format("Estimated energy value: %.1f", vm.getKcal()), vm.kcalProperty()
+ () -> String.format("Estimated energy value: %.1f kcal/100g", vm.getKcal()), vm.kcalProperty()
));
});
this.nutritionValueContainer.addEventHandler(KeyEvent.KEY_RELEASED, event -> {
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 d072669..d923ffd 100644
--- a/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java
+++ b/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java
@@ -2,6 +2,7 @@ package client.scenes.recipe;
import client.exception.UpdateException;
import client.scenes.FoodpalApplicationCtrl;
+import client.service.ShoppingListService;
import client.utils.Config;
import client.utils.ConfigService;
import client.utils.LocaleAware;
@@ -10,6 +11,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;
@@ -18,6 +20,9 @@ import java.nio.file.Path;
import java.util.Optional;
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.control.Button;
import javafx.scene.control.ComboBox;
@@ -44,8 +49,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;
@@ -62,12 +71,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
@@ -154,12 +165,20 @@ 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())
+ , 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());
@@ -386,7 +405,7 @@ public class RecipeDetailCtrl implements LocaleAware {
public void initializeComponents() {
initStepsIngredientsList();
// creates a new scale spinner with an arbitrary max scale
- scaleSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0, Double.MAX_VALUE, 1));
+ scaleSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(1, Double.MAX_VALUE, 1));
scaleSpinner.setEditable(true);
scaleSpinner.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
@@ -395,6 +414,23 @@ 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);
+ }
+
+ public void handleAddAllToShoppingList(ActionEvent actionEvent) {
+ System.out.println("handleAddAllToShoppingList");
+ // TODO BACKLOG Add overview screen
+ recipe.getIngredients().stream()
+ .filter(x -> x.getClass().equals(FormalIngredient.class))
+ .map(FormalIngredient.class::cast)
+ .forEach(x -> shoppingListService.putIngredient(x, recipe));
}
}
diff --git a/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java b/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java
index db5de91..e9192ab 100644
--- a/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java
+++ b/client/src/main/java/client/scenes/recipe/ScalableRecipeView.java
@@ -4,14 +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 DoubleProperty scaledKcal = new SimpleDoubleProperty();
+ private final IntegerProperty servings = new SimpleIntegerProperty();
+ private final DoubleProperty servingSize = new SimpleDoubleProperty();
public ScalableRecipeView(
Recipe recipe,
Double scale
@@ -22,10 +27,11 @@ public class ScalableRecipeView {
() -> Recipe.getScaled(this.recipe.get(), this.scale.get()),
this.recipe, this.scale);
this.scaled.bind(binding);
- }
-
- public double getScale() {
- return scale.get();
+ this.scaledKcal.bind(Bindings.createDoubleBinding(() -> this.scaled.get().kcal(), this.scaled));
+ this.servingSize.bind(Bindings.createDoubleBinding(
+ () -> this.scaled.get().weight() * ( 1.0 / this.servings.get()),
+ this.servings)
+ );
}
public Recipe getRecipe() {
@@ -36,15 +42,14 @@ public class ScalableRecipeView {
return scaled.get();
}
- public DoubleProperty scaleProperty() {
- return scale;
+ public DoubleProperty scaledKcalProperty() {
+ return scaledKcal;
}
- public ObjectProperty scaledProperty() {
- return scaled;
+ public IntegerProperty servingsProperty() {
+ return servings;
}
-
- public ObjectProperty recipeProperty() {
- return recipe;
+ public DoubleProperty servingSizeProperty() {
+ return servingSize;
}
}
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..ec08c62
--- /dev/null
+++ b/client/src/main/java/client/scenes/shopping/ShoppingListCtrl.java
@@ -0,0 +1,81 @@
+package client.scenes.shopping;
+
+import client.UI;
+import client.service.ShoppingListService;
+import client.utils.LocaleAware;
+import client.utils.LocaleManager;
+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.ListView;
+import javafx.stage.Modality;
+import javafx.stage.Stage;
+import javafx.util.Pair;
+
+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.getItems().setAll(
+ this.shopping.getItems()
+ );
+ }
+ 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);
+ refreshList();
+ });
+ stage.setScene(new Scene(root.getValue()));
+ stage.setTitle("My modal window");
+ stage.initModality(Modality.WINDOW_MODAL);
+ stage.initOwner(
+ ((Node)actionEvent.getSource()).getScene().getWindow() );
+ stage.show();
+ }
+
+ public void handleRemoveItem(ActionEvent actionEvent) {
+ var x = this.shoppingListView.getSelectionModel().getSelectedItem();
+ this.shopping.getItems().remove(x);
+ refreshList();
+ }
+}
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..9941ee6
--- /dev/null
+++ b/client/src/main/java/client/service/ShoppingListServiceImpl.java
@@ -0,0 +1,71 @@
+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) {
+
+ }
+
+ @Override
+ public FormalIngredient purgeIngredient(Long id) {
+ return null;
+ }
+
+ @Override
+ public FormalIngredient purgeIngredient(String ingredientName) {
+ FormalIngredient fi = getViewModel().getListItems().stream()
+ .filter(i ->
+ i.getKey().getIngredient().getName().equals(ingredientName))
+ .findFirst().orElseThrow(NullPointerException::new).getKey();
+ return null;
+ }
+
+ @Override
+ public void reset() {
+ getViewModel().getListItems().clear();
+ }
+
+ @Override
+ public List>> getItems() {
+ return getViewModel().getListItems();
+ }
+
+ @Override
+ public String makePrintable() {
+ return "TODO";
+ }
+}
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 @@
+
diff --git a/client/src/main/resources/client/scenes/nutrition/IngredientList.fxml b/client/src/main/resources/client/scenes/nutrition/IngredientList.fxml
index 653beaa..751ed0b 100644
--- a/client/src/main/resources/client/scenes/nutrition/IngredientList.fxml
+++ b/client/src/main/resources/client/scenes/nutrition/IngredientList.fxml
@@ -15,16 +15,16 @@
-
+
-
-
-
-
+
+
+
+
diff --git a/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml b/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml
index 127274c..602b827 100644
--- a/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml
+++ b/client/src/main/resources/client/scenes/recipe/RecipeDetailView.fxml
@@ -25,11 +25,16 @@
-
-
+
-
+
+
+
+
+
+
+
@@ -37,4 +42,6 @@
+
+
diff --git a/client/src/main/resources/client/scenes/shopping/ShoppingList.fxml b/client/src/main/resources/client/scenes/shopping/ShoppingList.fxml
new file mode 100644
index 0000000..272e30d
--- /dev/null
+++ b/client/src/main/resources/client/scenes/shopping/ShoppingList.fxml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml b/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml
new file mode 100644
index 0000000..aee4c20
--- /dev/null
+++ b/client/src/main/resources/client/scenes/shopping/ShoppingListItemAddModal.fxml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Unit...
+ Your ingredient...
+
+
+
+
+
+
+
diff --git a/client/src/main/resources/flag_zhc.png b/client/src/main/resources/flag_zhc.png
new file mode 100644
index 0000000..e14c147
Binary files /dev/null and b/client/src/main/resources/flag_zhc.png differ
diff --git a/client/src/main/resources/flag_zht.png b/client/src/main/resources/flag_zht.png
new file mode 100644
index 0000000..5c5b076
Binary files /dev/null and b/client/src/main/resources/flag_zht.png differ
diff --git a/client/src/main/resources/locale/lang.properties b/client/src/main/resources/locale/lang.properties
index df67ba5..59f8bb1 100644
--- a/client/src/main/resources/locale/lang.properties
+++ b/client/src/main/resources/locale/lang.properties
@@ -16,6 +16,8 @@ menu.label.preparation=Preparation
menu.button.add.recipe=Add Recipe
menu.button.add.ingredient=Add Ingredient
menu.button.add.step=Add Step
+menu.button.favourites=Favourites
+menu.button.ingredients=Ingredients
menu.button.remove.recipe=Remove Recipe
menu.button.remove.ingredient=Remove Ingredient
@@ -25,10 +27,21 @@ menu.button.edit=Edit
menu.button.clone=Clone
menu.button.print=Print recipe
+menu.ingredients.title=Nutrition value
+
+menu.button.add=Add
+menu.button.refresh=Refresh
+menu.button.delete=Delete
+menu.button.close=Close
+
+menu.search=Search...
+
menu.label.selected-langs=Languages
lang.en.display=English
lang.nl.display=Dutch
lang.pl.display=Polish
lang.tok.display=toki pona
-lang.tr.display=T\u00FCrk\u00E7e
\ No newline at end of file
+lang.tr.display=T\u00FCrk\u00E7e
+lang.zht.display=中文(台灣)
+lang.zhc.display=中文(中国大陆)
diff --git a/client/src/main/resources/locale/lang_en.properties b/client/src/main/resources/locale/lang_en.properties
index 2344205..bf41079 100644
--- a/client/src/main/resources/locale/lang_en.properties
+++ b/client/src/main/resources/locale/lang_en.properties
@@ -16,6 +16,8 @@ menu.label.preparation=Preparation
menu.button.add.recipe=Add Recipe
menu.button.add.ingredient=Add Ingredient
menu.button.add.step=Add Step
+menu.button.favourites=Favourites
+menu.button.ingredients=Ingredients
menu.button.remove.recipe=Remove Recipe
menu.button.remove.ingredient=Remove Ingredient
@@ -25,12 +27,23 @@ menu.button.edit=Edit
menu.button.clone=Clone
menu.button.print=Print recipe
+menu.ingredients.title=Nutrition value
+
+menu.button.add=Add
+menu.button.refresh=Refresh
+menu.button.delete=Delete
+menu.button.close=Close
+
menu.search=Search...
menu.label.selected-langs=Languages
+menu.shopping.title=Shopping list
+
lang.en.display=English
-lang.nl.display=Nederlands
-lang.pl.display=Polski
+lang.nl.display=Dutch
+lang.pl.display=Polish
lang.tok.display=toki pona
-lang.tr.display=T\u00FCrk\u00E7e
\ No newline at end of file
+lang.tr.display=T\u00FCrk\u00E7e
+lang.zht.display=中文(台灣)
+lang.zhc.display=中文(中国大陆)
diff --git a/client/src/main/resources/locale/lang_nl.properties b/client/src/main/resources/locale/lang_nl.properties
index 0a65cf8..f944251 100644
--- a/client/src/main/resources/locale/lang_nl.properties
+++ b/client/src/main/resources/locale/lang_nl.properties
@@ -10,26 +10,39 @@ button.ok=Ok
button.cancel=Annuleren
menu.label.recipes=Recepten
-menu.label.ingredients=Ingredinten
+menu.label.ingredients=Ingrediënten
menu.label.preparation=Bereiding
menu.button.add.recipe=Recept toevoegen
-menu.button.add.ingredient=Ingredint toevoegen
+menu.button.add.ingredient=Ingrediënt toevoegen
menu.button.add.step=Stap toevoegen
+menu.button.favourites=Favorieten
+menu.button.ingredients=Ingrediënten
menu.button.remove.recipe=Recept verwijderen
-menu.button.remove.ingredient=Ingredint verwijderen
+menu.button.remove.ingredient=Ingrediënt verwijderen
menu.button.remove.step=Stap verwijderen
menu.button.edit=Bewerken
menu.button.clone=Dupliceren
menu.button.print=Recept afdrukken
+menu.ingredients.title=Voedingswaarden
+
+menu.button.add=Toevoegen
+menu.button.refresh=Verversen
+menu.button.delete=Verwijderen
+menu.button.close=Sluiten
+
menu.label.selected-langs=Talen
+menu.shopping.title=Boodschappenlijst
+
menu.search=Zoeken...
-lang.en.display=English
+lang.en.display=Engels
lang.nl.display=Nederlands
-lang.pl.display=Polski
+lang.pl.display=Pools
lang.tok.display=toki pona
-lang.tr.display=T\u00FCrk\u00E7e
\ No newline at end of file
+lang.tr.display=T\u00FCrk\u00E7e
+lang.zht.display=中文(台灣)
+lang.zhc.display=中文(中国大陆)
diff --git a/client/src/main/resources/locale/lang_pl.properties b/client/src/main/resources/locale/lang_pl.properties
index 841d9b8..28695ad 100644
--- a/client/src/main/resources/locale/lang_pl.properties
+++ b/client/src/main/resources/locale/lang_pl.properties
@@ -16,6 +16,8 @@ menu.label.preparation=Przygotowanie
menu.button.add.recipe=Dodaj przepis
menu.button.add.ingredient=Dodaj składnik
menu.button.add.step=Dodaj instrukcję
+menu.button.favourites=Ulubione
+menu.button.ingredients=Składniki
menu.button.remove.recipe=Usuń przepis
menu.button.remove.ingredient=Usuń składnik
@@ -25,12 +27,23 @@ menu.button.edit=Edytuj
menu.button.clone=Duplikuj
menu.button.print=Drukuj przepis
+menu.ingredients.title=wartości odżywcze
+
+menu.button.add=Dodaj
+menu.button.refresh=Odśwież
+menu.button.delete=Usuń
+menu.button.close=Zamknij
+
menu.search=Szukaj...
menu.label.selected-langs=Języki
-lang.en.display=English
-lang.nl.display=Nederlands
+menu.shopping.title=Lista zakupw
+
+lang.en.display=Inglisz
+lang.nl.display=Holenderski
lang.pl.display=Polski
lang.tok.display=toki pona
-lang.tr.display=T\u00FCrk\u00E7e
\ No newline at end of file
+lang.tr.display=T\u00FCrk\u00E7e
+lang.zht.display=中文(台灣)
+lang.zhc.display=中文(中国大陆)
diff --git a/client/src/main/resources/locale/lang_tok.properties b/client/src/main/resources/locale/lang_tok.properties
index 1b44b6a..14cc3cd 100644
--- a/client/src/main/resources/locale/lang_tok.properties
+++ b/client/src/main/resources/locale/lang_tok.properties
@@ -16,6 +16,8 @@ menu.label.preparation=nasin pi pali moku ni
menu.button.add.recipe=o pali e lipu moku sin
menu.button.add.ingredient=o pali e kipisi moku sin
menu.button.add.step=o pali e nasin pi pali moku ni
+menu.button.favourites=ijo pi pona mute tawa sina
+menu.button.ingredients=kipisi moku mute
menu.button.remove.recipe=o weka e lipu moku ni
menu.button.remove.ingredient=o weka e kipisi moku ni
@@ -25,12 +27,22 @@ menu.button.edit=o pali
menu.button.clone=o sama
menu.button.print=o tawa lon lipu
+menu.ingredients.title=nanpa moku
+
+menu.button.add=o pali
+menu.button.refresh=o pali sin
+menu.button.delete=o weka
+menu.button.close=o pini
+
menu.search=o alasa
menu.label.selected-langs=toki wile
+menu.shopping.title=ijo wile mani mute
+
lang.en.display=toki Inli
lang.nl.display=toki Netelan
lang.pl.display=toki Posuka
lang.tok.display=toki pona
-lang.tr.display=toki Tuki
\ No newline at end of file
+lang.tr.display=toki Tuki
+lang.zht.display=toki Sonko (tan pi tenpo pini)
diff --git a/client/src/main/resources/locale/lang_tr.properties b/client/src/main/resources/locale/lang_tr.properties
index 1b4ceb6..4af12b8 100644
--- a/client/src/main/resources/locale/lang_tr.properties
+++ b/client/src/main/resources/locale/lang_tr.properties
@@ -16,6 +16,8 @@ menu.label.preparation=Haz\u0131rl\u0131k
menu.button.add.recipe=Tarif Ekle
menu.button.add.ingredient=Malzeme Ekle
menu.button.add.step=Ad\u0131m Ekle
+menu.button.favourites=Favoriler
+menu.button.ingredients=Malzemeler
menu.button.remove.recipe=Tarifi Sil
menu.button.remove.ingredient=Malzemeyi Sil
@@ -25,9 +27,23 @@ menu.button.edit=D\u00FCzenle
menu.button.clone=Kopyala
menu.button.print=Tarifi Yazd\u0131r
+menu.ingredients.title=besin değerleri
+
+menu.button.add=Ekle
+menu.button.refresh=yenilemek
+menu.button.delete=sil
+menu.button.close=kapat
+
+menu.search=Arama...
+
menu.label.selected-langs=Diller
-lang.en.display=\u0130ngilizce
-lang.nl.display=Hollandaca
-lang.pl.display=Leh\u00E7e
+menu.shopping.title=Al??veri? listesi
+
+lang.en.display=English
+lang.nl.display=Nederlands
+lang.pl.display=Polski
+lang.tok.display=toki pona
lang.tr.display=T\u00FCrk\u00E7e
+lang.zht.display=中文(台灣)
+lang.zhc.display=中文(中国大陆)
diff --git a/client/src/main/resources/locale/lang_zhc.properties b/client/src/main/resources/locale/lang_zhc.properties
new file mode 100644
index 0000000..646ce54
--- /dev/null
+++ b/client/src/main/resources/locale/lang_zhc.properties
@@ -0,0 +1,38 @@
+add.ingredient.title=添加配料
+add.recipe.title=创建食谱
+add.step.title=添加步骤
+
+add.ingredient.label=配料
+add.recipe.label=食谱名称
+add.step.label=步骤
+
+button.ok=确认
+button.cancel=取消
+
+menu.label.recipes=食谱
+menu.label.ingredients=配料
+menu.label.preparation=准备步骤
+
+menu.button.add.recipe=创建食谱
+menu.button.add.ingredient=添加配料
+menu.button.add.step=添加步骤
+
+menu.button.remove.recipe=清除食谱
+menu.button.remove.ingredient=清除配料
+menu.button.remove.step=清除步骤
+
+menu.button.edit=编辑
+menu.button.clone=复制
+menu.button.print=打印食谱
+
+menu.search=搜索
+
+menu.label.selected-langs=语言
+
+lang.en.display=English
+lang.nl.display=Nederlands
+lang.pl.display=Polski
+lang.tok.display=toki pona
+lang.tr.display=T\u00FCrk\u00E7e
+lang.zht.display=中文(台灣)
+lang.zhc.display=中文(中国大陆)
diff --git a/client/src/main/resources/locale/lang_zht.properties b/client/src/main/resources/locale/lang_zht.properties
new file mode 100644
index 0000000..fe5c3fb
--- /dev/null
+++ b/client/src/main/resources/locale/lang_zht.properties
@@ -0,0 +1,38 @@
+add.ingredient.title=添加配料
+add.recipe.title=創建食譜
+add.step.title=添加步驟
+
+add.ingredient.label=配料
+add.recipe.label=食譜名稱
+add.step.label=步驟
+
+button.ok=確認
+button.cancel=取消
+
+menu.label.recipes=食譜
+menu.label.ingredients=配料
+menu.label.preparation=制備步驟
+
+menu.button.add.recipe=創建食譜
+menu.button.add.ingredient=添加配料
+menu.button.add.step=添加步驟
+
+menu.button.remove.recipe=清除食譜
+menu.button.remove.ingredient=清除配料
+menu.button.remove.step=清除步驟
+
+menu.button.edit=編輯
+menu.button.clone=複製
+menu.button.print=列印食譜
+
+menu.search=搜索
+
+menu.label.selected-langs=語言
+
+lang.en.display=English
+lang.nl.display=Nederlands
+lang.pl.display=Polski
+lang.tok.display=toki pona
+lang.tr.display=T\u00FCrk\u00E7e
+lang.zht.display=中文(台灣)
+lang.zhc.display=中文(中国大陆)
diff --git a/client/src/test/java/client/Ingredient/IngredientControllerTest.java b/client/src/test/java/client/Ingredient/IngredientControllerTest.java
new file mode 100644
index 0000000..65dd6af
--- /dev/null
+++ b/client/src/test/java/client/Ingredient/IngredientControllerTest.java
@@ -0,0 +1,176 @@
+package client.Ingredient;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import commons.Ingredient;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.event.ActionEvent;
+import javafx.scene.control.Button;
+import javafx.scene.control.ListView;
+import javafx.stage.Stage;
+import javafx.stage.Window;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.delete;
+import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+@EnabledIfEnvironmentVariable(named = "DISPLAY", matches = ".+")
+@WireMockTest(httpPort = 8080)
+class IngredientControllerMockTest {
+
+ private IngredientController controller;
+ private ListView ingredientListView;
+
+ // starting javaFX and allow use of listview and alert usage
+ @BeforeAll
+ static void initJavaFx() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(latch::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ latch.countDown();
+ }
+ assertTrue(latch.await(3, TimeUnit.SECONDS), "JavaFX Platform failed to start");
+ Platform.setImplicitExit(false);
+ }
+//inject fxml fields and create controller + mock UI
+ @BeforeEach
+ void setup() throws Exception {
+ controller = new IngredientController();
+
+ ingredientListView = new ListView<>();
+ ingredientListView.setItems(FXCollections.observableArrayList(
+ new Ingredient("Bread", 1, 2, 3),
+ new Ingredient("Cheese", 2, 2, 2),
+ new Ingredient("Ham", 3, 3, 3)
+ ));
+
+ setPrivateField(controller, "ingredientListView", ingredientListView);
+ setPrivateField(controller, "deleteButton", new Button("Delete"));
+ }
+
+ // pick ingredient -> backend says not in use -> fake delete ingredient
+ @Test
+ void deleteIngredientWhenNotUsedCallsUsageThenDeleteAndClearsList() throws Exception {
+ Ingredient selected = ingredientListView.getItems().get(0);
+ ingredientListView.getSelectionModel().select(selected);
+
+ stubFor(get(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage"))
+ .willReturn(okJson("{\"ingredientId\":" + selected.getId() + ",\"usedInRecipes\":0}")));
+
+ stubFor(delete(urlEqualTo("/api/ingredients/" + selected.getId()))
+ .willReturn(ok()));
+
+ // safe close for show and wait, run controller on JavaFX
+ try (DialogCloser closer = startDialogCloser()) {
+ runOnFxThreadAndWait(() -> controller.handleDeleteIngredient(new ActionEvent()));
+ }
+
+ verify(getRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage")));
+ verify(deleteRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId())));
+
+ assertEquals(0, ingredientListView.getItems().size());
+ }
+
+ //select ingredient -> if used backend says it and show warning -> safety delete but shouldn't happen
+ @Test
+ void deleteIngredientWhenUsedShowsWarningAndDoesNotDeleteIfDialogClosed() throws Exception {
+ Ingredient selected = ingredientListView.getItems().get(1);
+ ingredientListView.getSelectionModel().select(selected);
+
+ stubFor(get(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage"))
+ .willReturn(okJson("{\"ingredientId\":" + selected.getId() + ",\"usedInRecipes\":2}")));
+
+ stubFor(delete(urlEqualTo("/api/ingredients/" + selected.getId()))
+ .willReturn(ok()));
+
+ //safe close as if user selected cancel
+ try (DialogCloser closer = startDialogCloser()) {
+ runOnFxThreadAndWait(() -> controller.handleDeleteIngredient(new ActionEvent()));
+ }
+
+ // check usage but not delete
+ verify(getRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId() + "/usage")));
+ verify(0, deleteRequestedFor(urlEqualTo("/api/ingredients/" + selected.getId())));
+
+ assertEquals(3, ingredientListView.getItems().size());
+ }
+
+ // fxml helper
+ private static void setPrivateField(Object target, String fieldName, Object value) throws Exception {
+ Field f = target.getClass().getDeclaredField(fieldName);
+ f.setAccessible(true);
+ f.set(target, value);
+ }
+
+ //controller on JavaFX
+ private static void runOnFxThreadAndWait(Runnable action) throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ try {
+ action.run();
+ } finally {
+ latch.countDown();
+ }
+ });
+ assertTrue(latch.await(8, TimeUnit.SECONDS), "FX action timed out");
+ }
+
+ // safe close so that show and wait doesn't wait forever
+ private static DialogCloser startDialogCloser() {
+ AtomicBoolean running = new AtomicBoolean(true);
+
+ Thread t = new Thread(() -> {
+ while (running.get()) {
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException ignored) {
+ }
+
+ Platform.runLater(() -> {
+ for (Window w : Window.getWindows()) {
+ if (w instanceof Stage stage && stage.isShowing()) {
+ stage.close();
+ }
+ }
+ });
+ }
+ }, "javafx-dialog-closer");
+
+ t.setDaemon(true);
+ t.start();
+
+ return new DialogCloser(running);
+ }
+
+ // dialog closer
+ private static final class DialogCloser implements AutoCloseable {
+ private final AtomicBoolean running;
+
+ private DialogCloser(AtomicBoolean running) {
+ this.running = running;
+ }
+
+ @Override
+ public void close() {
+ running.set(false);
+ }
+ }
+}
diff --git a/commons/src/main/java/commons/FormalIngredient.java b/commons/src/main/java/commons/FormalIngredient.java
index 88d713a..2c5c055 100644
--- a/commons/src/main/java/commons/FormalIngredient.java
+++ b/commons/src/main/java/commons/FormalIngredient.java
@@ -76,4 +76,15 @@ public class FormalIngredient extends RecipeIngredient implements Scalable throw new IllegalStateException("Unexpected value: " + ri);
}).toList();
return new Recipe(recipe.getId(), recipe.getName(), recipe.getLocale(), i, recipe.getPreparationSteps());
-
+ }
+ public double kcal() {
+ final double PER = 100; // Gram
+ return
+ this.ingredients.stream().mapToDouble(RecipeIngredient::getKcal).sum() /
+ weight() * PER;
+ }
+ public double weight() {
+ return this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum();
}
-}
\ No newline at end of file
+}
diff --git a/commons/src/main/java/commons/RecipeIngredient.java b/commons/src/main/java/commons/RecipeIngredient.java
index 33d9b75..f5dc86f 100644
--- a/commons/src/main/java/commons/RecipeIngredient.java
+++ b/commons/src/main/java/commons/RecipeIngredient.java
@@ -1,5 +1,6 @@
package commons;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.persistence.Entity;
@@ -92,4 +93,8 @@ public abstract class RecipeIngredient {
public int hashCode() {
return Objects.hash(id, ingredient);
}
+ @JsonIgnore
+ public abstract double getKcal();
+ @JsonIgnore
+ public abstract double getBaseAmount();
}
diff --git a/commons/src/main/java/commons/VagueIngredient.java b/commons/src/main/java/commons/VagueIngredient.java
index efa2fc3..1c4cbac 100644
--- a/commons/src/main/java/commons/VagueIngredient.java
+++ b/commons/src/main/java/commons/VagueIngredient.java
@@ -50,4 +50,13 @@ public class VagueIngredient extends RecipeIngredient {
public int hashCode() {
return Objects.hashCode(description);
}
+
+ @Override
+ public double getKcal() {
+ return 0;
+ }
+ @Override
+ public double getBaseAmount() {
+ return 0;
+ }
}
diff --git a/locc.sh b/locc.sh
new file mode 100644
index 0000000..93a0fea
--- /dev/null
+++ b/locc.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+set -u
+
+# 1. Check for git repo
+if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "Error: current directory is not a git repository." >&2
+ exit 1
+fi
+
+# 2. Define path patterns
+PATH_CLIENT='client/src/**/*.java'
+PATH_SERVER='server/src/**/*.java'
+PATH_COMMONS='commons/src/**/*.java'
+PATH_PROD='*/src/main/*.java'
+PATH_TEST='*/src/test/*.java'
+PATH_ALL='*.java'
+
+# 3. Helper functions
+
+# Standard count: Includes imports, respects WS changes
+count_lines_standard() {
+ git show --format="" --patch "$1" -- "$2" \
+ | grep -E '^\+[^+/][^/]+$' \
+ | grep -v '+ *[*@]' \
+ | wc -l
+}
+
+# Strict count: Ignores imports, ignores pure WS changes
+count_lines_strict() {
+ git show --format="" --patch -w "$1" -- "$2" \
+ | grep -E '^\+[^+/][^/]+$' \
+ | grep -v 'import' \
+ | grep -v '+ *[*@]' \
+ | wc -l
+}
+
+echo "Analyzing commits on 'main'..." >&2
+
+# 4. Main Loop
+# Use %ae for Author Email
+git log --no-merges --pretty=format:'%H %ae' main | {
+
+ declare -A client_count
+ declare -A server_count
+ declare -A commons_count
+ declare -A prod_count
+ declare -A test_count
+ declare -A total_count
+ declare -A strict_count
+ declare -A seen_users
+
+ while read -r hash raw_email; do
+ # Normalize email to lowercase
+ email=$(echo "$raw_email" | tr '[:upper:]' '[:lower:]')
+ seen_users["$email"]=1
+
+ # Run counts (Standard)
+ c_add=$(count_lines_standard "$hash" "$PATH_CLIENT")
+ s_add=$(count_lines_standard "$hash" "$PATH_SERVER")
+ k_add=$(count_lines_standard "$hash" "$PATH_COMMONS")
+ p_add=$(count_lines_standard "$hash" "$PATH_PROD")
+ t_add=$(count_lines_standard "$hash" "$PATH_TEST")
+ all_add=$(count_lines_standard "$hash" "$PATH_ALL")
+
+ # Run count (Strict)
+ strict_add=$(count_lines_strict "$hash" "$PATH_ALL")
+
+ # Accumulate
+ client_count["$email"]=$(( ${client_count["$email"]:-0} + c_add ))
+ server_count["$email"]=$(( ${server_count["$email"]:-0} + s_add ))
+ commons_count["$email"]=$(( ${commons_count["$email"]:-0} + k_add ))
+ prod_count["$email"]=$(( ${prod_count["$email"]:-0} + p_add ))
+ test_count["$email"]=$(( ${test_count["$email"]:-0} + t_add ))
+ total_count["$email"]=$(( ${total_count["$email"]:-0} + all_add ))
+ strict_count["$email"]=$(( ${strict_count["$email"]:-0} + strict_add ))
+
+ printf "." >&2
+ done
+
+ echo "" >&2
+ echo "Done." >&2
+
+ # 5. Print Table
+ # Widths: Email=40, Others=10, Strict=13
+ printf "%-40s | %-10s | %-10s | %-10s | %-10s | %-10s | %-10s | %-13s\n" \
+ "User Email" "Client" "Server" "Commons" "Prod" "Test" "Total" "Total (Strict)"
+ printf "%s\n" "-----------------------------------------|------------|------------|------------|------------|------------|------------|----------------"
+
+ for email in "${!seen_users[@]}"; do
+ echo "$email"
+ done | sort | while read -r e; do
+ printf "%-40s | %-10d | %-10d | %-10d | %-10d | %-10d | %-10d | %-13d\n" \
+ "$e" \
+ "${client_count[$e]:-0}" \
+ "${server_count[$e]:-0}" \
+ "${commons_count[$e]:-0}" \
+ "${prod_count[$e]:-0}" \
+ "${test_count[$e]:-0}" \
+ "${total_count[$e]:-0}" \
+ "${strict_count[$e]:-0}"
+ done
+}
diff --git a/server/src/test/java/server/PortCheckerTest.java b/server/src/test/java/server/PortCheckerTest.java
index 2843d99..a77e4d9 100644
--- a/server/src/test/java/server/PortCheckerTest.java
+++ b/server/src/test/java/server/PortCheckerTest.java
@@ -31,31 +31,37 @@ class PortCheckerTest {
}
}
@Test
- void invalidPort(){
- PortChecker checker = new PortChecker();
-
- assertThrows(IllegalArgumentException.class, ()-> {
- checker.isPortAvailable(-1);
- }
- );
- assertThrows(IllegalArgumentException.class, ()-> {
- checker.isPortAvailable(65536);
- }
- );
- }
- @Test
- void findFreePort() throws IOException {
+ void findNotDefaultFreePort() throws IOException {
PortChecker checker = new PortChecker();
int port = checker.findFreePort();
- int defaultPort = 8080;
- int lastPort = 8090;
+ int lowestPossiblePort = 0;
+ int highestPossiblePort = 65535;
- boolean greaterOrEqual = port >= defaultPort;
- boolean lessOrEqual = port <= lastPort;
- boolean inRange = greaterOrEqual && lessOrEqual;
- boolean isItFree = checker.isPortAvailable(port);
+ assertTrue(port > lowestPossiblePort);
+ assertTrue(port <= highestPossiblePort);
+ assertTrue(checker.isPortAvailable(port));
+ }
+ @Test
+ void findDefaultFreePort() throws IOException {
+ PortChecker checker = new PortChecker();
- assertTrue(inRange);
+ boolean free = checker.isPortAvailable(8080);
+
+ assertTrue(free);
+ assertEquals(checker.findFreePort(),8080);
+ }
+
+ @Test
+ void invalidFreePort(){
+ PortChecker checker = new PortChecker();
+
+ assertThrows(IllegalArgumentException.class, () ->
+ checker.isPortAvailable(-1)
+ );
+
+ assertThrows(IllegalArgumentException.class, () ->
+ checker.isPortAvailable(65536)
+ );
}
}
\ No newline at end of file
diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java
index f4d8c77..9e397c7 100644
--- a/server/src/test/java/server/api/RecipeControllerTest.java
+++ b/server/src/test/java/server/api/RecipeControllerTest.java
@@ -18,6 +18,7 @@ import server.service.RecipeService;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.LongStream;
@@ -67,7 +68,9 @@ public class RecipeControllerTest {
.mapToObj(x -> new Recipe(
null,
"Recipe " + x,
- "en", List.of(), List.of()))
+ "en",
+ List.of(),
+ List.of()))
.toList();
controller = new RecipeController(
recipeService,
@@ -80,8 +83,11 @@ public class RecipeControllerTest {
if (tags.contains("test-from-init-data")) {
ids = LongStream
.range(0, NUM_RECIPES)
- .map(idx -> recipeRepository.save(recipes.get((int) idx)).getId())
- .boxed().toList();
+ .map(idx -> recipeRepository
+ .save(recipes.get((int) idx))
+ .getId())
+ .boxed()
+ .toList();
}
// Some tests need to know the stored IDs of objects
@@ -117,9 +123,65 @@ public class RecipeControllerTest {
controller.getRecipes(
Optional.empty(),
Optional.empty(),
- Optional.of(List.of("en", "nl"))).getBody().size());;
+ Optional.of(List.of("en", "nl")))
+ .getBody()
+ .size());
}
+ @Test
+ @Tag("test-from-init-data")
+ public void getRecipesWithNegativeLimit(){
+ assertEquals(0,
+ controller.getRecipes(Optional.empty(),
+ Optional.of(-2),
+ Optional.of(List.of("en")))
+ .getBody().size());
+
+ }
+ @Test
+ @Tag("test-from-init-data")
+ public void getRecipesWithSearch() {
+
+ recipeRepository.save(new Recipe(
+ null,
+ "banana pie",
+ "en",
+ List.of(),
+ List.of()));
+
+ assertEquals(1, controller.getRecipes(
+ Optional.of("banana"),
+ Optional.empty(),
+ Optional.of(List.of("en"))).getBody().size());
+
+ assertEquals("banana pie", Objects.requireNonNull(controller.getRecipes(
+ Optional.of("banana"),
+ Optional.empty(),
+ Optional.of(List.of("en"))).getBody()).getFirst().getName());
+ }
+ @Test
+ @Tag("test-from-init-data")
+ public void getRecipesWithZeroLimit() {
+ assertEquals(0, controller.getRecipes(
+ Optional.empty(),
+ Optional.of(0),
+ Optional.of(List.of("en"))).getBody().size());
+ }
+ @Test
+ @Tag("test-from-init-data")
+ public void getRecipesWithEmptySearch() {
+ var response = controller.getRecipes(
+ Optional.of(" "),
+ Optional.empty(),
+ Optional.of(List.of("en"))
+ );
+ assertEquals(recipes.size(), response.getBody().size());
+ }
+
+
+
+
+
@Test
@Tag("test-from-init-data")
public void getSomeRecipes() {
@@ -129,7 +191,9 @@ public class RecipeControllerTest {
controller.getRecipes(
Optional.empty(),
Optional.of(LIMIT),
- Optional.of(List.of("en"))).getBody().size());
+ Optional.of(List.of("en")))
+ .getBody()
+ .size());
}
@Test
@@ -140,7 +204,9 @@ public class RecipeControllerTest {
controller.getRecipes(
Optional.empty(),
Optional.empty(),
- Optional.of(List.of("en", "nl"))).getBody().size());
+ Optional.of(List.of("en", "nl")))
+ .getBody()
+ .size());
}
@Test
@@ -151,7 +217,9 @@ public class RecipeControllerTest {
controller.getRecipes(
Optional.empty(),
Optional.empty(),
- Optional.of(List.of("nl"))).getBody().size());
+ Optional.of(List.of("nl")))
+ .getBody()
+ .size());
}
@Test
@Tag("test-from-init-data")
@@ -161,7 +229,9 @@ public class RecipeControllerTest {
controller.getRecipes(
Optional.empty(),
Optional.of(LIMIT),
- Optional.of(List.of("en", "nl"))).getBody().size());
+ Optional.of(List.of("en", "nl")))
+ .getBody()
+ .size());
}
@Test
@@ -172,7 +242,8 @@ public class RecipeControllerTest {
// The third item in the input list is the same as the third item retrieved from the database
assertEquals(
recipes.get(CHECK_INDEX),
- controller.getRecipe(recipeIds.get(CHECK_INDEX)).getBody());
+ controller.getRecipe(recipeIds.get(CHECK_INDEX))
+ .getBody());
}
@Test
@@ -181,7 +252,8 @@ public class RecipeControllerTest {
// There does not exist a recipe with ID=3 since there are no items in the repository.
assertEquals(
HttpStatus.NOT_FOUND,
- controller.getRecipe((long) CHECK_INDEX).getStatusCode());
+ controller.getRecipe((long) CHECK_INDEX)
+ .getStatusCode());
}
@Test
@@ -192,7 +264,8 @@ public class RecipeControllerTest {
// The object has been successfully deleted
assertEquals(HttpStatus.OK,
- controller.deleteRecipe(recipeIds.get(DELETE_INDEX)).getStatusCode());
+ controller.deleteRecipe(recipeIds.get(DELETE_INDEX))
+ .getStatusCode());
}
@Test
@@ -211,7 +284,8 @@ public class RecipeControllerTest {
public void deleteOneRecipeFail() {
final Long DELETE_INDEX = 5L;
assertEquals(HttpStatus.BAD_REQUEST,
- controller.deleteRecipe(DELETE_INDEX).getStatusCode());
+ controller.deleteRecipe(DELETE_INDEX)
+ .getStatusCode());
}
@Test
@@ -219,10 +293,16 @@ public class RecipeControllerTest {
@Tag("need-ids")
public void updateOneRecipeHasNewData() {
final int UPDATE_INDEX = 5;
- Recipe newRecipe = controller.getRecipe(recipeIds.get(UPDATE_INDEX)).getBody();
+ Recipe newRecipe = controller.getRecipe(recipeIds
+ .get(UPDATE_INDEX))
+ .getBody();
+
newRecipe.setName("New recipe");
controller.updateRecipe(newRecipe.getId(), newRecipe);
+
assertEquals("New recipe",
- recipeRepository.getReferenceById(recipeIds.get(UPDATE_INDEX)).getName());
+ recipeRepository.getReferenceById(recipeIds
+ .get(UPDATE_INDEX))
+ .getName());
}
}