Compare commits

..

65 commits

Author SHA1 Message Date
827fe195a9
fix: static member of Config declaring all languages supported by the application 2026-01-23 00:38:25 +01:00
dda01f3bfe
chore(lang): adds Chinese that looks just a liiiiiiittle bit different 2026-01-23 00:38:24 +01:00
a3c08170d3
chore(lang): zh_TW 2026-01-23 00:37:14 +01:00
Aysegul Aydinlik
b82a8f7d33 Merge branch 'searchbar-fix' into 'main'
added searching multiple things

Closes #82

See merge request cse1105/2025-2026/teams/csep-team-76!91
2026-01-23 00:33:57 +01:00
1fd3b26faa Merge branch 'feature/shopping-list-crud' into 'main'
CRUD functionality for shopping list

Closes #80

See merge request cse1105/2025-2026/teams/csep-team-76!85
2026-01-23 00:33:08 +01:00
186e74ddd3 Merge branch 'feature/client-shopping-list-view' into 'main'
Client shopping list view

Closes #79

See merge request cse1105/2025-2026/teams/csep-team-76!84
2026-01-23 00:31:57 +01:00
Rithvik Sriram
f1fc55f674 Merge branch 'extra-server-tests' into 'main'
Added a few extra tests in RecipeControllerTest

See merge request cse1105/2025-2026/teams/csep-team-76!90
2026-01-22 23:24:26 +01:00
bfe6505c54 Merge branch 'fix/favourites-rendering' into 'main'
fix: favourites rendering properly after a list change propagated from server.

See merge request cse1105/2025-2026/teams/csep-team-76!89
2026-01-22 23:21:48 +01:00
f281ce1333
chore: cleanup unused code in foodpal 2026-01-22 22:56:07 +01:00
1542117ac6
fix: refresh the favourites list if any change occurs to the original list 2026-01-22 22:55:56 +01:00
Rithvik Sriram
4d57f84ccb Added a few extra tests in RecipeControllerTest 2026-01-22 22:37:49 +01:00
Aysegul Aydinlik
be1b4b9ee3 added a feature and meaningful addition 2026-01-22 21:50:13 +01:00
ad199ea244 Merge branch 'feature/client-search-fix' into 'main'
fix: Search bar immediately clears search params and returns server contents after ESCAPE key-press

See merge request cse1105/2025-2026/teams/csep-team-76!87
2026-01-22 21:42:59 +01:00
7cddc0303c
fix: Search bar immediately clears search params and returns server contents after ESCAPE key-press 2026-01-22 21:04:28 +01:00
4c341aadec Merge branch 'bugfix/ingredients-list-ctrl-renderer' into 'main'
fix: into initializeComponents

See merge request cse1105/2025-2026/teams/csep-team-76!86
2026-01-22 20:54:57 +01:00
d23eb34a00
fix: into initializeComponents 2026-01-22 20:51:10 +01:00
234c8c3d64
feat(client/shopping): fxml definition for various UI elements 2026-01-22 19:54:49 +01:00
670de432c5
feat(client/shopping): delegate list view cell rendering and make add element handler 2026-01-22 19:54:33 +01:00
795926298e
feat(client/shopping): create custom editable shopping list cell element 2026-01-22 19:53:27 +01:00
f03c12cc0f
feat(client/shopping): create add ingredient modal 2026-01-22 19:52:53 +01:00
143b73c23f Merge branch 'feature/add-duplicate-error-message' into 'main'
Added a special warning message when user tries to add duplicate ingredients

See merge request cse1105/2025-2026/teams/csep-team-76!58
2026-01-22 19:03:01 +01:00
76852fcd4f Merge branch 'prevent-server-dependancy' into 'main'
added a Dialog sequence for when the server is not up

Closes #58

See merge request cse1105/2025-2026/teams/csep-team-76!79
2026-01-22 18:59:15 +01:00
483617c5bf
feat(client/fxml): add relevant components 2026-01-22 18:46:52 +01:00
21b2465b91
feat(client/service/shop): functional service backend 2026-01-22 18:46:33 +01:00
c0107d752a
feat(client/shoplist): UI control logic for add all to list 2026-01-22 18:46:06 +01:00
40cfc0b98e
fix(client/logging): added slf4j-api to client pom.xml 2026-01-22 18:44:25 +01:00
Natalia Cholewa
d732aee6de fix: pipeline not passing :( 2026-01-22 17:33:53 +01:00
Natalia Cholewa
6cfd6ab82c feat: shopping list view 2026-01-22 17:22:39 +01:00
70dfc7e983 fix: non-functional API 2026-01-22 17:21:50 +01:00
5315fe3df4 feat(client/nutrition-model): init modules for shopping list; missing impl but it has interface :) 2026-01-22 17:21:50 +01:00
Mei Chang van der Werff
9077df5164 Merge branch 'fix/Ingredients-ordered' into 'main'
Sorts ingredients in alphabetic order

Closes #75

See merge request cse1105/2025-2026/teams/csep-team-76!80
2026-01-22 16:43:47 +01:00
Rithvik Sriram
22dd603746 stray typo fix 2026-01-22 16:12:58 +01:00
Rithvik Sriram
cb4a93aeab Fixed merge conflicts and rebased 2026-01-22 16:12:20 +01:00
Rithvik Sriram
a1df0f5b24 Made a Duplicate Exception class and made that be called instead for better robustness and consistency 2026-01-22 16:01:17 +01:00
Rithvik Sriram
e5f7df7318 Fixed pipeline issues and added comment 2026-01-22 15:51:21 +01:00
Rithvik Sriram
b985aa283f added a special warning message when user tries to add duplicate ingredients 2026-01-22 15:51:21 +01:00
Mei Chang van der Werff
274a48c5c2 Merge branch 'ImprovedServerTests' into 'main'
Fixed PortChecker Server Test

See merge request cse1105/2025-2026/teams/csep-team-76!81
2026-01-22 15:34:58 +01:00
23d6d6bbf6 Merge branch 'client/feature-servings' into 'main'
feat(client/servings): inferring serving size from user input

Closes #72

See merge request cse1105/2025-2026/teams/csep-team-76!74
2026-01-22 15:24:46 +01:00
Aysegul Aydinlik
14a4e157ea Merge branch 'testing-client' into 'main'
added IngredientControllerTest

Closes #77

See merge request cse1105/2025-2026/teams/csep-team-76!82
2026-01-22 15:17:03 +01:00
4e901f7847
fix: servings minimum as One 2026-01-22 14:52:33 +01:00
d83494c2a1
chore: move things around 2026-01-22 14:52:33 +01:00
6931367145
feat(client/main): append main UI logic to handle serving size rendering 2026-01-22 14:52:33 +01:00
8615187628
feat(client/serving): declare UI infrastructure and data deps for servings 2026-01-22 14:52:33 +01:00
23447a96d6
feat(commons/recipe): introduce weight sum calculations 2026-01-22 14:52:32 +01:00
b7bbebe222
feat(client/scaling): initial implementation 2026-01-22 14:51:03 +01:00
Aysegul Aydinlik
2dd8627f58 added IngredientControllerTest + fix pipeline 2026-01-22 14:50:52 +01:00
Aysegul Aydinlik
9603742220 added IngredientControllerTest + fix comments 2026-01-22 14:44:27 +01:00
46ba82bc0d Merge branch 'client/feature-kcal-eval' into 'main'
feat(client/kcal): kcal/100g per recipe evaluation

Closes #71

See merge request cse1105/2025-2026/teams/csep-team-76!73
2026-01-22 14:40:38 +01:00
Aysegul Aydinlik
f7a1deb9b1 added IngredientControllerTest + fix pipeline fail 2026-01-22 14:31:49 +01:00
Aysegul Aydinlik
0acbb13a49 added IngredientControllerTest 2026-01-22 14:25:35 +01:00
Mei Chang van der Werff
9222999505 made the tests more readable... 2026-01-22 02:16:22 +01:00
Mei Chang van der Werff
4d17f10323 updated the port tests to the fixed findFreePort code 2026-01-22 02:15:40 +01:00
Oskar Rasieński
5915b658e6 Merge branch 'lang-fix' into 'main'
Lang fix

See merge request cse1105/2025-2026/teams/csep-team-76!78
2026-01-22 01:21:43 +01:00
Mei Chang van der Werff
55b3811deb Sorts ingredients in alphabetic order 2026-01-22 01:08:54 +01:00
Rithvik Sriram
110e5e8163 added a Dialog sequence for when the server is not up, which was previously just a log msg. Now the user gets to provide their own server. 2026-01-21 22:48:07 +01:00
47d8aa7721
fix: got it 2026-01-21 16:12:00 +01:00
3a0120dc33
fix: getting to it 2026-01-21 15:53:58 +01:00
72f29467bd
fix: no more NaN 2026-01-21 14:58:06 +01:00
Oskar Rasieński
2ca0acbeef checkstyle fix... 2026-01-21 14:50:14 +01:00
7e5137b317
feat(client/kcal): ui logic for kcal eval 2026-01-21 14:47:46 +01:00
8422734a3f
feat(commons/kcal): kcal eval infrastructure 2026-01-21 14:47:46 +01:00
Mei Chang van der Werff
06dde451f3 Merge branch 'feature/fav-highlight' into 'main'
A favourited recipe now becomes blue in text

Closes #74

See merge request cse1105/2025-2026/teams/csep-team-76!77
2026-01-21 14:46:35 +01:00
Oskar Rasieński
969d052230 translated ingredients menu 2026-01-21 14:34:57 +01:00
Mei Chang van der Werff
8989cab770 A favourited recipe now becomes blue in text 2026-01-21 14:21:30 +01:00
Oskar Rasieński
638220bb07 Fixed lang in main menu 2026-01-21 13:51:11 +01:00
46 changed files with 1413 additions and 215 deletions

View file

@ -21,7 +21,13 @@
<artifactId>commons</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!-- Source: https://mvnrepository.com/artifact/org.slf4j/slf4j-api -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>

View file

@ -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) {

View file

@ -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<WebSocketDataService<Long, Recipe>>() {}).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<WebSocketDataService<Long, Ingredient>>() {}).toInstance(
new WebSocketDataService<>()
);

View file

@ -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");

View file

@ -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;
}
}

View file

@ -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<Recipe> recipeList;
@FXML
private ListView<Ingredient> ingredientListView;
private final ListProperty<Recipe> favouriteRecipeList = new SimpleListProperty<>();
@FXML
private Button addRecipeButton;
@ -84,7 +88,8 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@FXML
private ToggleButton favouritesOnlyToggle;
private List<Recipe> 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<Recipe> view = allRecipes;
List<Recipe> 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<String> 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<Ingredient>() {
@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();
}
}

View file

@ -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<Ingredient> 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 {

View file

@ -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", "zht", "zhc", "tok", "tr");
langSelectMenu.getItems().setAll(Config.languages);
langSelectMenu.setValue(manager.getLocale().getLanguage());
langSelectMenu.setConverter(new StringConverter<String>() {
@Override

View file

@ -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<String> languages = List.of("en", "nl", "pl", "tok", "tr");
final List<String> languages = List.of(Config.languages);
this.selectedLanguages = this.configService.getConfig().getRecipeLanguages();
this.updateMenuButtonDisplay();

View file

@ -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<Recipe> 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<Recipe> applyMultiTermAndFilter(List<Recipe> 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());
}
}

View file

@ -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<ButtonType> 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<String> 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<ButtonType> result = retry.showAndWait();
if(result.isEmpty() || result.get() == ButtonType.NO){
return false;
}
}
}
}
}

View file

@ -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 -> {

View file

@ -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<RecipeIngredient> ingredientList = recipe.getIngredients();
List<RecipeIngredient> ingredientList = recipe
.getIngredients()
.stream()
.sorted(Comparator.comparing(ingredient -> ingredient
.getIngredient()
.getName()))
.toList();
this.ingredients = FXCollections.observableArrayList(ingredientList);
}

View file

@ -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
}
}

View file

@ -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<Long, Recipe> webSocketDataService;
private final ShoppingListService shoppingListService;
public Spinner<Double> scaleSpinner;
public Label inferredKcalLabel;
public Spinner<Integer> 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<Long, Recipe> 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));
}
}

View file

@ -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> recipe = new SimpleObjectProperty<>();
private final ObjectProperty<Recipe> 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<Recipe> scaledProperty() {
return scaled;
public IntegerProperty servingsProperty() {
return servings;
}
public ObjectProperty<Recipe> recipeProperty() {
return recipe;
public DoubleProperty servingSizeProperty() {
return servingSize;
}
}

View file

@ -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<Pair<FormalIngredient, Optional<String>>> {
private Node makeEditor() {
HBox editor = new HBox();
Spinner<Double> 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<FormalIngredient, Optional<String>> 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<FormalIngredient, Optional<String>> 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<FormalIngredient, Optional<String>> newValue) {
super.commitEdit(newValue);
}
}

View file

@ -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<Pair<FormalIngredient, Optional<String>>> 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<ShoppingListNewItemPromptCtrl, Parent> 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();
}
}

View file

@ -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<Ingredient> selected = new SimpleObjectProperty<>();
private final ObjectProperty<Unit> selectedUnit = new SimpleObjectProperty<>();
private final ServerUtils server;
private final LocaleManager localeManager;
private Consumer<FormalIngredient> newValueConsumer;
public MenuButton unitSelect;
public Spinner<Double> amountSelect;
@Inject
public ShoppingListNewItemPromptCtrl(ServerUtils server, LocaleManager localeManager) {
this.server = server;
this.localeManager = localeManager;
}
public void setNewValueConsumer(Consumer<FormalIngredient> 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 <T> void makeMenuItems(
MenuButton menu,
Iterable<T> items,
Function<T, String> labelMapper,
Consumer<T> 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;
}
}

View file

@ -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<Pair<FormalIngredient, Optional<String>>> getItems() {
throw new NotImplementedException();
}
@Override
public String makePrintable() {
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,6 @@
package client.service;
import commons.FormalIngredient;
public class ShoppingListItem {
private FormalIngredient i;
}

View file

@ -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<Pair<FormalIngredient, Optional<String>>> getItems();
public abstract String makePrintable();
}

View file

@ -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<FormalIngredient, Optional<String>> 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<Pair<FormalIngredient, Optional<String>>> getItems() {
return getViewModel().getListItems();
}
@Override
public String makePrintable() {
return "TODO";
}
}

View file

@ -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<Pair<FormalIngredient, Optional<String>>> listItems = new SimpleListProperty<>(FXCollections.observableArrayList());
public void addArbitrary() {
throw new NotImplementedException();
}
public ObservableList<Pair<FormalIngredient, Optional<String>>> getListItems() {
return listItems.get();
}
}

View file

@ -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<String> recipeLanguages = new ArrayList<>();
private String serverUrl = "http://localhost:8080";

View file

@ -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");

View file

@ -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<String> 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());
}

View file

@ -70,6 +70,9 @@
<ToggleButton fx:id="favouritesOnlyToggle" text="Favourites" onAction="#toggleFavouritesView" />
</HBox>
<Button fx:id="shoppingListButton"
onAction="#openShoppingListWindow"
text="Shopping List" />
<Button fx:id="manageIngredientsButton"
onAction="#openIngredientsPopup"
text="Ingredients..." />

View file

@ -15,16 +15,16 @@
<Insets top="12" right="12" bottom="12" left="12"/>
</padding>
<Label text="Ingredients" style="-fx-font-size: 18px; -fx-font-weight: bold;"/>
<Label fx:id="ingredientsLabel" text="Ingredients" style="-fx-font-size: 18px; -fx-font-weight: bold;"/>
<ListView fx:id="ingredientListView" VBox.vgrow="ALWAYS"/>
<ButtonBar>
<buttons>
<Button text="Add" onAction="#addIngredient"/>
<Button text="Refresh" onAction="#refresh"/>
<Button text="Delete" onAction="#deleteSelected"/>
<Button text="Close" onAction="#close"/>
<Button fx:id="addButton" text="Add" onAction="#addIngredient"/>
<Button fx:id="refreshButton" text="Refresh" onAction="#refresh"/>
<Button fx:id="deleteButton" text="Delete" onAction="#deleteSelected"/>
<Button fx:id="closeButton" text="Close" onAction="#close"/>
</buttons>
</ButtonBar>
</VBox>

View file

@ -25,11 +25,16 @@
<Button fx:id="removeRecipeButton" mnemonicParsing="false" onAction="#removeSelectedRecipe" text="Remove Recipe" />
<Button fx:id="printRecipeButton" mnemonicParsing="false" onAction="#printRecipe" text="Print Recipe" />
<Button fx:id="favouriteButton" onAction="#toggleFavourite" text="☆" />
<Label>Scale: </Label>
<Spinner fx:id="scaleSpinner" />
<Button onAction="#handleAddAllToShoppingList">Shop</Button>
</HBox>
<ComboBox fx:id="langSelector" onAction="#changeLanguage" />
<HBox>
<ComboBox fx:id="langSelector" onAction="#changeLanguage" />
<Label>Scale: </Label>
<Spinner fx:id="scaleSpinner" />
<Label>Servings: </Label>
<Spinner fx:id="servingsSpinner" />
</HBox>
<!-- Ingredients -->
<fx:include source="RecipeIngredientList.fxml" fx:id="ingredientList" />
@ -37,4 +42,6 @@
<!-- Preparation -->
<fx:include source="RecipeStepList.fxml" fx:id="stepList"
VBox.vgrow="ALWAYS" maxWidth="Infinity" />
<Label fx:id="inferredServeSizeLabel" />
<Label fx:id="inferredKcalLabel" />
</VBox>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.control.TitledPane?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.VBox?>
<TitledPane animated="false" collapsible="false" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity"
minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" text="Shopping List"
fx:controller="client.scenes.shopping.ShoppingListCtrl"
xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/25">
<VBox>
<ListView fx:id="shoppingListView" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308"
AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0"
AnchorPane.topAnchor="0.0"/>
<HBox>
<Button onAction="#handleAddItem">Add</Button>
<Button onAction="#handleRemoveItem">Delete</Button>
</HBox>
</VBox>
</TitledPane>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="client.scenes.shopping.ShoppingListNewItemPromptCtrl"
prefHeight="400.0" prefWidth="600.0">
<VBox>
<HBox>
<Spinner fx:id="amountSelect" />
<MenuButton fx:id="unitSelect">Unit...</MenuButton>
<MenuButton fx:id="ingredientSelection">Your ingredient...</MenuButton>
</HBox>
<HBox>
<Button onAction="#confirmAdd">Confirm</Button>
<Button onAction="#cancelAdd">Cancel</Button>
</HBox>
</VBox>
</AnchorPane>

View file

@ -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,6 +27,15 @@ 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

View file

@ -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,13 +27,22 @@ 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
lang.zht.display=中文(台灣)

View file

@ -16,6 +16,8 @@ menu.label.preparation=Bereiding
menu.button.add.recipe=Recept 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=Ingrediënt verwijderen
@ -25,12 +27,21 @@ 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
lang.zht.display=中文(台灣)

View file

@ -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,21 @@ 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 zakupów
lang.en.display=Inglisz
lang.nl.display=Holenderski
lang.pl.display=Polski
lang.tok.display=toki pona
lang.tr.display=T\u00FCrk\u00E7e

View file

@ -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,10 +27,19 @@ 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

View file

@ -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,18 @@ 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
menu.search=Aramak
menu.shopping.title=Al??veri? listesi
lang.en.display=English
lang.nl.display=Nederlands

View file

@ -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<Ingredient> 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);
}
}
}

View file

@ -76,4 +76,15 @@ public class FormalIngredient extends RecipeIngredient implements Scalable<Forma
public int hashCode() {
return Objects.hash(super.hashCode(), amount, unitSuffix);
}
@Override
public double getKcal() {
final double PER_GRAMS = 100;
return ingredient.kcalPer100g() * amountInBaseUnit() / PER_GRAMS;
}
@Override
public double getBaseAmount() {
return amountInBaseUnit();
}
}

View file

@ -198,7 +198,15 @@ public class Recipe {
default -> 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();
}
}
}

View file

@ -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();
}

View file

@ -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;
}
}

102
locc.sh Normal file
View file

@ -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
}

View file

@ -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)
);
}
}

View file

@ -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());
}
}