Compare commits

...

73 commits

Author SHA1 Message Date
b4c5652070
docs: minutes week 9 (final) 2026-01-24 13:25:52 +01:00
Natalia Cholewa
b942862dc9 Merge branch 'feature/pie-chart' into 'main'
Pie chart for nutrients

See merge request cse1105/2025-2026/teams/csep-team-76!94
2026-01-23 21:44:30 +01:00
Natalia Cholewa
bc852ce3d6 fix: pie chart refreshing improperly 2026-01-23 21:40:03 +01:00
Natalia Cholewa
7946cd3df2 feat: translations for pie chart 2026-01-23 21:39:58 +01:00
Natalia Cholewa
c1301dad28 feat: pie chart for nutrients 2026-01-23 21:39:52 +01:00
167a80c84a Merge branch 'fix/normalized-units' into 'main'
Units now get normalized

Closes #76

See merge request cse1105/2025-2026/teams/csep-team-76!83
2026-01-23 21:20:25 +01:00
3e8442e517 Merge branch 'AddOverview' into 'main'
last points of 4.5

Closes #83 and #81

See merge request cse1105/2025-2026/teams/csep-team-76!92
2026-01-23 15:13:42 +01:00
91a8a13369
chore: update README to express implemented features 2026-01-23 15:05:15 +01:00
Natalia Cholewa
33e7c7386e docs: agenda for week 09 2026-01-23 12:21:29 +01:00
Natalia Cholewa
a5cdcaac14 docs: agenda for week 09 2026-01-23 12:20:03 +01:00
Natalia Cholewa
2c54833c6b docs: agenda for week 09 2026-01-23 12:18:34 +01:00
Maria Dumitrescu
b5348a4c24 feedback w8 2026-01-23 09:27:13 +01:00
Aysegul Aydinlik
6d4c33fa10 finished 4.5 last remaining points 2026-01-23 02:55:27 +01:00
e17a75fcd5 Merge branch 'lang/zh-XX' into 'main'
chore(lang): zh_CN & zh_TW translations into application

Closes #69

See merge request cse1105/2025-2026/teams/csep-team-76!69
2026-01-23 00:44:02 +01:00
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
Mei Chang van der Werff
e3e176939c fix: test fix after changes 2026-01-22 20:26:52 +01:00
Mei Chang van der Werff
91997ae251 2 decimal limit 2026-01-22 20:18:06 +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
Mei Chang van der Werff
3ca526e0f1 switched to switch case 2026-01-22 19:07:26 +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
Mei Chang van der Werff
37fd3b18be added tests 2026-01-22 15:06:30 +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
Mei Chang van der Werff
8238e9b462 fix: magic numbers 2026-01-22 14:50:48 +01:00
Mei Chang van der Werff
abe9750b9f Ingredients in the ingredient view are alphabetically ordered 2026-01-22 14:45:20 +01:00
Aysegul Aydinlik
9603742220 added IngredientControllerTest + fix comments 2026-01-22 14:44:27 +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
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
54 changed files with 2294 additions and 233 deletions

View file

@ -6,27 +6,27 @@ The project uses Java 25. Make sure you have the correct Java version installed.
### Client
The client needs to be launched **after** a server is already running, see Usage.Server section:
```
mvn -pl client -am javafx:run
```
### Server
By default, the server listens to the port `8080`.
[TODO(1)]:: Configurable port.
By default, the server listens to the port `8080`. If the server is not available, the next immediate free port is opened. You can run the server with:
```
mvn -pl server -am spring-boot:run
```
## Features
## Implemented features
- Recipe tracking in an intuitive GUI.
- Ability to input arbitrary amounts of an ingredient in a recipe. The design is very human. See Manual.Ingredients for more usage details.
- Native localization in more than 2, and less than 4 languages.
- Configurable via JSON, See Manual.Configuration.
- Full 4.1 basic requirements criteria. Print/Export functionality exports a plain-text file of the recipe to a file in a location of the user's choosing.
- Full 4.2 WebSocket modelling. We have implemented all points in the backlog and created a compliant solution using distinct STOMP messages that distribute handling to multiple distinct callbacks. We also implemented a meaningful addition as an "Updated at {Time}" utility prompt such that the application hints to the user when it may be time to perform a manual refresh to stay up-to-date.
- Full 4.3 Nutrition view and list of ingredients. We have implemented each criteria in the product backlog pertaining to this section to a satisfactory level.
When in confusion, the user should consult Manual.Ingredients section of this file to see how to add an informal or formal ingredient.
We also include a meaningful addition into the application as a pie chart describing the nutritional composition of each recipe between proteins, carbohydrates, and fats, so that the end user gets a much more straightforward presentation for their dietary choices.
- Full 4.4 search functionality implemented. Each criteria is met to satisfactory standards and the client submits a search query to the backend with a list of parameters, to which the client proceeds to respond.
- Full 4.5 Shopping list functionality. We implemented a functional shopping list to which the user can add/delete/edit ingredients to, as well as an Add Overview when the user decides to add ingredients of a recipe into their shopping list. Printing the list to a file is also supported.
- Full 4.6 functionality. All parts of the UI buttons have been linked to their respective resource items. The application has support for 7 languages, including English, Dutch, Chinese (Simplified/Traditional), Polish, Turkish, and toki pona. We include a national flag for each language in a language selection menu for easier interaction with the end user.
## Manual
@ -58,5 +58,5 @@ The configuration is with JSON, read from `config.json` in the working directory
### Ingredients
- To input a **formal** ingredient, you write the numeric amount in the first input box, then the Unit in the selection dropdown, and then write the name of the ingredient, e.g. salt, apples, etc. should it be not visible already on the platform.
- To input a **formal** ingredient, you write the numeric amount in the first input box, then the Unit in the selection dropdown, and then write the name of the ingredient, e.g. salt, apples, etc. should it be not visible already from the dropdown.
- To input an **informal** ingredient, describe the amount in the first input box, like "some of", or "a sprinkle of", then select "<NONE>" in the unit selection box, and write the name of your ingredient or pick from one of the availables from the dropdown.

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,10 +28,15 @@ 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);
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");
var mainCtrl = INJECTOR.getInstance(MainCtrl.class);

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,15 +31,20 @@ import commons.ws.messages.Message;
import commons.ws.messages.UpdateRecipeMessage;
import jakarta.inject.Inject;
import javafx.application.Platform;
import javafx.beans.property.ListProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ToggleButton;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
@ -66,8 +70,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@FXML
public ListView<Recipe> recipeList;
@FXML
private ListView<Ingredient> ingredientListView;
private final ListProperty<Recipe> favouriteRecipeList = new SimpleListProperty<>();
@FXML
private Button addRecipeButton;
@ -88,8 +91,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@FXML
private Button manageIngredientsButton;
private List<Recipe> allRecipes = new ArrayList<>();
@FXML
private Label updatedBadge;
@ -287,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();
@ -325,8 +333,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
logger.severe(msg);
printError(msg);
}
allRecipes = new ArrayList<>(recipes);
recipeList.getItems().setAll(recipes);
applyRecipeFilterAndKeepSelection();
showUpdatedBadge();
@ -422,16 +429,20 @@ public class FoodpalApplicationCtrl implements LocaleAware {
public void applyRecipeFilterAndKeepSelection() {
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
Long selectedId = selected == null ? null : selected.getId();
List<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());
}
// restore selection if possible
if (selectedId != null) {
@ -452,114 +463,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.recipeDetailController.refreshFavouriteButton();
this.recipeDetailController.setVisible(!recipeList.getItems().isEmpty());
}
//Delete Ingredient button click
@FXML
private void handleDeleteIngredient() {
// Get selected ingredient
Ingredient selectedIngredient = ingredientListView.getSelectionModel().getSelectedItem();
if (selectedIngredient == null) {
// Show an error message if no ingredient is selected
showError("No ingredient selected", "Please select an ingredient to delete.");
return;
}
// Check if the ingredient is used in any recipe
checkIngredientUsage(selectedIngredient);
}
// Check if ingredient is used in any recipe before deleting
private void checkIngredientUsage(Ingredient ingredient) {
try {
long usageCount = server.getIngredientUsage(ingredient.getId()); // Check ingredient usage via ServerUtils
if (usageCount > 0) {
// If ingredient is used, show a warning dialog
showWarningDialog(ingredient, usageCount);
} else {
// If not used, delete
deleteIngredient(ingredient);
}
} catch (IOException | InterruptedException e) {
showError("Error", "Failed to check ingredient usage: " + e.getMessage());
}
}
private void deleteIngredient(Ingredient ingredient) {
try {
server.deleteIngredient(ingredient.getId()); // Call ServerUtils to delete the ingredient
showConfirmation("Success", "Ingredient '" + ingredient.getName() + "' has been deleted.");
refreshIngredientList(); // refresh the ingredient list
} catch (IOException | InterruptedException e) {
showError("Error", "Failed to delete ingredient: " + e.getMessage());
}
}
private void showWarningDialog(Ingredient ingredient, long usedInRecipes) {
Alert alert = new Alert(Alert.AlertType.WARNING);
}
private void showError(String title, String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void showConfirmation(String title, String message) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void refreshIngredientList() {
// Refresh
ingredientListView.getItems().clear();
}
@FXML
private void handleAddIngredient() {
//ask the user for the ingredient name
TextInputDialog dialog = new TextInputDialog();
dialog.setTitle("Add Ingredient");
dialog.setHeaderText("Enter the ingredient name:");
dialog.setContentText("Ingredient:");
// Wait for the user to enter a value
Optional<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 {
@ -611,6 +514,14 @@ public class FoodpalApplicationCtrl implements LocaleAware {
updatedBadgeTimer.playFromStart();
}
public void openShoppingListWindow() throws IOException {
var root = UI.getFXML().load(ShoppingListCtrl.class, "client", "scenes", "shopping", "ShoppingList.fxml");
Stage stage = new Stage();
stage.setTitle(this.getLocaleString("menu.shopping.title"));
stage.setScene(new Scene(root.getValue()));
stage.show();
}
}

View file

@ -1,5 +1,6 @@
package client.scenes.Ingredient;
import client.exception.DuplicateIngredientException;
import client.scenes.nutrition.NutritionDetailsCtrl;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
@ -66,8 +67,8 @@ public class IngredientListCtrl implements LocaleAware {
return this.localeManager;
}
@FXML
public void initialize() {
@Override
public void initializeComponents() {
ingredientListView.setCellFactory(list -> new ListCell<>() {
@Override
protected void updateItem(Ingredient item, boolean empty) {
@ -113,6 +114,8 @@ public class IngredientListCtrl implements LocaleAware {
refresh(); // reload list from server
} catch (IOException | InterruptedException e) {
showError("Failed to create ingredient: " + e.getMessage());
} catch (DuplicateIngredientException e) {
throw new RuntimeException(e);
}
}

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;
@ -46,7 +47,7 @@ public class LangSelectMenuCtrl implements LocaleAware {
@Override
public void initializeComponents() {
langSelectMenu.getItems().setAll("en", "pl", "nl", "tok", "tr");
langSelectMenu.getItems().setAll(Config.languages);
langSelectMenu.setValue(manager.getLocale().getLanguage());
langSelectMenu.setConverter(new StringConverter<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

@ -0,0 +1,234 @@
package client.scenes.nutrition;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import com.google.inject.Inject;
import commons.FormalIngredient;
import commons.Ingredient;
import commons.Recipe;
import commons.VagueIngredient;
import javafx.fxml.FXML;
import javafx.scene.chart.PieChart;
import java.util.List;
import java.util.Objects;
import java.util.logging.Logger;
public class NutritionPieChartCtrl implements LocaleAware {
/**
* Nutrition info for a recipe or an ingredient.
*
* @param protein The protein this recipe/ingredient has
* @param fat The fat this recipe/ingredient has
* @param carbs The carbs this recipe/ingredient has
*/
private record CompleteNutritionInfo(
double protein,
double fat,
double carbs
) {
/**
* Create a new {@link CompleteNutritionInfo} with zeroed values.
*
* @return A {@link CompleteNutritionInfo} with zeroed values.
*/
static CompleteNutritionInfo zero() {
return new CompleteNutritionInfo(0.0, 0.0, 0.0);
}
/**
* Check whether this instance has all values set to zero.
*
* @return Whether all values (protein, carbs, fat) are zero.
*/
public boolean isZero() {
return this.protein() == 0.0
&& this.carbs() == 0.0
&& this.fat() == 0.0;
}
/**
* Scale this object by an amount. Multiplies all values by amount.
*
* @param amount The amount to scale it by.
* @return The newly scaled nutrition info.
*/
public CompleteNutritionInfo scaled(double amount) {
return new CompleteNutritionInfo(
this.protein() * amount,
this.fat() * amount,
this.carbs() * amount
);
}
/**
* Add another nutrition info object to this object.
*
* @param rhs The nutrition info object to add.
* @return A new nutrition info object with the sum of both objects' nutrients.
*/
public CompleteNutritionInfo add(CompleteNutritionInfo rhs) {
return new CompleteNutritionInfo(
this.protein() + rhs.protein(),
this.fat() + rhs.fat(),
this.carbs() + rhs.carbs()
);
}
}
private final LocaleManager localeManager;
private final Logger logger = Logger.getLogger(NutritionPieChartCtrl.class.getName());
@FXML
PieChart pieChart;
private Recipe recipe;
@Inject
NutritionPieChartCtrl(
LocaleManager manager
) {
this.localeManager = manager;
}
/**
* Get the data for this pie chart based on the current recipe.
* <p>
* This accumulates all the nutrients (properly scaled) from all ingredients in this recipe
* and returns a summary with data points.
* </p>
* <p>
* The list will be of length 3 if any nutrients are added, or 0 if all the nutrients
* sum up to be 0.
* </p>
* @return The data for this pie chart, as a labeled list of data.
*/
private List<PieChart.Data> getPieChartData() {
CompleteNutritionInfo info = this.recipe
.getIngredients()
.stream()
.map(ri -> {
Ingredient i = ri.getIngredient();
logger.info("Mapping ingredient " + i.toString());
switch (ri) {
case FormalIngredient fi -> {
return new CompleteNutritionInfo(
i.getProteinPer100g(),
i.getFatPer100g(),
i.getCarbsPer100g()
)
.scaled(fi.getBaseAmount());
}
case VagueIngredient vi -> {
return new CompleteNutritionInfo(
i.getProteinPer100g(),
i.getFatPer100g(),
i.getCarbsPer100g()
)
.scaled(vi.getBaseAmount());
}
default -> {
return null;
}
}
})
.filter(Objects::nonNull)
.reduce(CompleteNutritionInfo::add)
.orElseGet(CompleteNutritionInfo::zero);
this.logger.info( "Updated data: " + info.toString() );
if (info.isZero()) {
return List.of();
}
return List.of(
new PieChart.Data(
this.getLocaleString("menu.nutrition.protein"),
info.protein()
),
new PieChart.Data(
this.getLocaleString("menu.nutrition.fat"),
info.fat()
),
new PieChart.Data(
this.getLocaleString("menu.nutrition.carbs"),
info.carbs()
)
);
}
/**
* Set the current recipe to be displayed in the pie chart.
* The pie chart will be refreshed.
* <p>
* The pie chart will disappear if all the nutrients in the recipe
* are zero.
* </p>
* @param recipe The recipe to display.
*/
public void setRecipe(Recipe recipe) {
this.recipe = recipe;
this.refresh();
}
@Override
public void updateText() {
List<PieChart.Data> data = this.pieChart.getData();
if (data.isEmpty()) return;
final int EXPECTED_DATA_SIZE = 3;
if (data.size() != EXPECTED_DATA_SIZE) return;
data.get(0).setName(this.getLocaleString("menu.nutrition.protein"));
data.get(1).setName(this.getLocaleString("menu.nutrition.fat"));
final int TWO = 2;
data.get(TWO).setName(this.getLocaleString("menu.nutrition.carbs"));
}
@Override
public LocaleManager getLocaleManager() {
return this.localeManager;
}
/**
* Refresh the data in this pie chart.
*/
@SuppressWarnings("checkstyle:MagicNumber")
public void refresh() {
if (this.recipe == null) {
this.pieChart.setVisible(false);
logger.info("Refreshing pie chart with no recipe");
return;
}
logger.info("Refreshing pie chart with recipe");
this.pieChart.setVisible(true);
if (this.pieChart.getData().isEmpty()) {
this.pieChart.getData().setAll(
this.getPieChartData()
);
} else {
List<PieChart.Data> fresh = this.getPieChartData();
List<PieChart.Data> data = this.pieChart.getData();
if (fresh.isEmpty()) {
data.clear();
return;
}
data.get(0).setPieValue(fresh.get(0).getPieValue());
data.get(1).setPieValue(fresh.get(1).getPieValue());
final int TWO = 2;
data.get(TWO).setPieValue(fresh.get(TWO).getPieValue());
}
}
public void initializeComponents() {
final double START_ANGLE = 60.0;
this.pieChart.setClockwise(true);
this.pieChart.setStartAngle(START_ANGLE);
this.refresh();
}
}

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,8 @@ package client.scenes.recipe;
import client.exception.UpdateException;
import client.scenes.FoodpalApplicationCtrl;
import client.scenes.nutrition.NutritionPieChartCtrl;
import client.service.ShoppingListService;
import client.utils.Config;
import client.utils.ConfigService;
import client.utils.LocaleAware;
@ -10,6 +12,7 @@ import client.utils.PrintExportService;
import client.utils.server.ServerUtils;
import client.utils.WebSocketDataService;
import com.google.inject.Inject;
import commons.FormalIngredient;
import commons.Recipe;
import java.io.File;
@ -20,7 +23,10 @@ import java.util.function.BiConsumer;
import java.util.function.Consumer;
import javafx.beans.binding.Bindings;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
@ -34,6 +40,8 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
import javafx.stage.DirectoryChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
/**
* Controller for the recipe detail view.
@ -46,9 +54,12 @@ public class RecipeDetailCtrl implements LocaleAware {
private final FoodpalApplicationCtrl appCtrl;
private final ConfigService configService;
private final WebSocketDataService<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;
@ -65,12 +76,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
@ -94,6 +107,9 @@ public class RecipeDetailCtrl implements LocaleAware {
@FXML
private ComboBox<String> langSelector;
@FXML
private NutritionPieChartCtrl pieChartController;
private ListView<Recipe> getParentRecipeList() {
return this.appCtrl.recipeList;
}
@ -124,7 +140,6 @@ public class RecipeDetailCtrl implements LocaleAware {
*
* @throws IOException Upon invalid recipe response.
* @throws InterruptedException Upon request interruption.
*
* @see FoodpalApplicationCtrl#refresh()
*/
private void refresh() throws IOException, InterruptedException {
@ -157,7 +172,7 @@ 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);
@ -167,14 +182,21 @@ public class RecipeDetailCtrl implements LocaleAware {
Double.isNaN(this.recipeView.scaledKcalProperty().get()) ?
0.0 : this.recipeView.scaledKcalProperty().get())
, this.recipeView.scaledKcalProperty()));
recipeView.servingsProperty().set(servingsSpinner.getValue());
inferredServeSizeLabel.textProperty().bind(Bindings.createStringBinding(
() -> String.format("Inferred size per serving: %.1f g", recipeView.servingSizeProperty().get()),
recipeView.servingSizeProperty()));
// expose the scaled view to list controllers
this.ingredientListController.refetchFromRecipe(this.recipeView.getScaled());
this.stepListController.refetchFromRecipe(this.recipeView.getScaled());
this.pieChartController.setRecipe(recipe);
return;
}
this.ingredientListController.refetchFromRecipe(recipe);
this.stepListController.refetchFromRecipe(recipe);
this.pieChartController.setRecipe(recipe);
}
/**
@ -325,6 +347,7 @@ public class RecipeDetailCtrl implements LocaleAware {
PrintExportService.exportToFile(recipeText, dirPath, filename);
}
}
/**
* Toggles the favourite status of the currently viewed recipe in the
* application configuration and writes the changes to disk.
@ -402,6 +425,47 @@ public class RecipeDetailCtrl implements LocaleAware {
// triggers a UI update each time the spinner changes to a different value.
setCurrentlyViewedRecipe(recipe);
});
langSelector.getItems().addAll("en", "nl", "pl", "tok");
servingsSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1));
servingsSpinner.setEditable(true);
servingsSpinner.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
return;
}
setCurrentlyViewedRecipe(recipe);
});
langSelector.getItems().addAll(Config.languages);
}
@FXML
public void handleAddAllToShoppingList(ActionEvent actionEvent) {
Recipe ingredientSource = (recipeView != null) ? recipeView.getScaled() : recipe;
var ingredients = ingredientSource.getIngredients().stream()
.map(ri -> {
if (ri instanceof FormalIngredient fi) {
return fi;
}
return new FormalIngredient(
ri.getIngredient(),
1,
""
);
})
.toList();
var pair = client.UI.getFXML().load(
client.scenes.shopping.AddOverviewCtrl.class,
"client", "scenes", "shopping", "AddOverview.fxml"
);
var ctrl = pair.getKey();
ctrl.setContext(recipe, ingredients);
Stage stage = new Stage();
stage.setTitle("Add to shopping list");
stage.initModality(Modality.WINDOW_MODAL);
stage.initOwner(((Node) actionEvent.getSource()).getScene().getWindow());
stage.setScene(new Scene(pair.getValue()));
stage.showAndWait();
}
}

View file

@ -4,15 +4,19 @@ import commons.Recipe;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
public class ScalableRecipeView {
private final ObjectProperty<Recipe> recipe = new SimpleObjectProperty<>();
private final ObjectProperty<Recipe> scaled = new SimpleObjectProperty<>();
private final DoubleProperty scale = new SimpleDoubleProperty();
private final SimpleDoubleProperty scaledKcal = new SimpleDoubleProperty();
private final DoubleProperty scaledKcal = new SimpleDoubleProperty();
private final IntegerProperty servings = new SimpleIntegerProperty();
private final DoubleProperty servingSize = new SimpleDoubleProperty();
public ScalableRecipeView(
Recipe recipe,
Double scale
@ -24,10 +28,10 @@ public class ScalableRecipeView {
this.recipe, this.scale);
this.scaled.bind(binding);
this.scaledKcal.bind(Bindings.createDoubleBinding(() -> this.scaled.get().kcal(), this.scaled));
}
public double getScale() {
return scale.get();
this.servingSize.bind(Bindings.createDoubleBinding(
() -> this.scaled.get().weight() * ( 1.0 / this.servings.get()),
this.servings)
);
}
public Recipe getRecipe() {
@ -38,23 +42,14 @@ public class ScalableRecipeView {
return scaled.get();
}
public double getScaledKcal() {
return scaledKcal.get();
}
public DoubleProperty scaleProperty() {
return scale;
}
public ObjectProperty<Recipe> scaledProperty() {
return scaled;
}
public ObjectProperty<Recipe> recipeProperty() {
return recipe;
}
public SimpleDoubleProperty scaledKcalProperty() {
public DoubleProperty scaledKcalProperty() {
return scaledKcal;
}
public IntegerProperty servingsProperty() {
return servings;
}
public DoubleProperty servingSizeProperty() {
return servingSize;
}
}

View file

@ -0,0 +1,244 @@
package client.scenes.shopping;
import client.service.ShoppingListService;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import com.google.inject.Inject;
import commons.FormalIngredient;
import commons.Ingredient;
import commons.Recipe;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.cell.ComboBoxTableCell;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.stage.Stage;
import javafx.util.converter.DoubleStringConverter;
import java.util.List;
import java.util.Optional;
public class AddOverviewCtrl implements LocaleAware {
private final ShoppingListService shoppingListService;
private final LocaleManager localeManager;
private String sourceRecipeName;
private final ObservableList<AddOverviewRow> rows = FXCollections.observableArrayList();
@FXML
private TableView<AddOverviewRow> overviewTable;
@FXML
private TableColumn<AddOverviewRow, String> nameColumn;
@FXML
private TableColumn<AddOverviewRow, Double> amountColumn;
@FXML
private TableColumn<AddOverviewRow, String> unitColumn;
@FXML
public void initialize() {
initializeComponents();
}
private static final List<String> DEFAULT_UNITS =
List.of("", "g", "kg", "ml", "l", "tbsp");
@Inject
public AddOverviewCtrl(ShoppingListService shoppingListService, LocaleManager localeManager) {
this.shoppingListService = shoppingListService;
this.localeManager = localeManager;
}
@Override
public void initializeComponents() {
overviewTable.setEditable(true);
nameColumn.setCellValueFactory(c -> c.getValue().nameProperty());
amountColumn.setCellValueFactory(c -> c.getValue().amountProperty().asObject());
unitColumn.setCellValueFactory(c -> c.getValue().unitProperty());
nameColumn.setCellFactory(TextFieldTableCell.forTableColumn());
nameColumn.setOnEditCommit(e -> e.getRowValue().setName(e.getNewValue()));
amountColumn.setCellFactory(TextFieldTableCell.forTableColumn(new DoubleStringConverter()));
amountColumn.setOnEditCommit(e -> e.getRowValue().setAmount(
e.getNewValue() == null ? 0.0 : e.getNewValue()
));
var unitOptions = FXCollections.observableArrayList(DEFAULT_UNITS);
unitColumn.setCellFactory(ComboBoxTableCell.forTableColumn(unitOptions));
unitColumn.setOnEditCommit(e -> e.getRowValue().setUnit(e.getNewValue()));
overviewTable.setItems(rows);
}
@Override
public void updateText() {
}
@Override
public LocaleManager getLocaleManager() {
return localeManager;
}
public void setContext(Recipe recipe, List<FormalIngredient> ingredients) {
this.sourceRecipeName = recipe == null ? null : recipe.getName();
rows.clear();
for (FormalIngredient fi : ingredients) {
rows.add(AddOverviewRow.fromFormalIngredient(fi));
}
}
@FXML
private void handleAddRow() {
TextInputDialog nameDialog = new TextInputDialog();
nameDialog.setTitle("Add item");
nameDialog.setHeaderText("Add an ingredient");
nameDialog.setContentText("Name:");
Optional<String> nameOpt = nameDialog.showAndWait();
if (nameOpt.isEmpty() || nameOpt.get().isBlank()) {
return;
}
TextInputDialog amountDialog = new TextInputDialog("0");
amountDialog.setTitle("Add item");
amountDialog.setHeaderText("Amount");
amountDialog.setContentText("Amount (number):");
double amount = 0.0;
Optional<String> amountOpt = amountDialog.showAndWait();
if (amountOpt.isPresent()) {
try {
amount = Double.parseDouble(amountOpt.get().trim());
} catch (NumberFormatException ignored) {
amount = 0.0;
}
}
rows.add(AddOverviewRow.arbitrary(nameOpt.get().trim(), amount, ""));
overviewTable.getSelectionModel().selectLast();
overviewTable.scrollTo(rows.size() - 1);
}
@FXML
private void handleRemoveSelected() {
AddOverviewRow selected = overviewTable.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
}
rows.remove(selected);
}
@FXML
private void handleConfirm() {
for (AddOverviewRow row : rows) {
FormalIngredient fi = row.toFormalIngredient();
if (sourceRecipeName == null || sourceRecipeName.isBlank()) {
shoppingListService.putIngredient(fi);
} else {
shoppingListService.putIngredient(fi, sourceRecipeName);
}
}
closeWindow();
}
@FXML
private void handleCancel() {
closeWindow();
}
private void closeWindow() {
Stage stage = (Stage) overviewTable.getScene().getWindow();
stage.close();
}
public static class AddOverviewRow {
private Ingredient backingIngredient;
private final StringProperty name = new SimpleStringProperty("");
private final DoubleProperty amount = new SimpleDoubleProperty(0.0);
private final StringProperty unit = new SimpleStringProperty("");
public static AddOverviewRow fromFormalIngredient(FormalIngredient fi) {
AddOverviewRow r = new AddOverviewRow();
r.backingIngredient = fi.getIngredient();
r.name.set(fi.getIngredient().getName());
r.amount.set(fi.getAmount());
r.unit.set(fi.getUnitSuffix() == null ? "" : fi.getUnitSuffix());
return r;
}
public static AddOverviewRow arbitrary(String name, double amount, String unit) {
AddOverviewRow r = new AddOverviewRow();
r.backingIngredient = null;
r.name.set(name == null ? "" : name);
r.amount.set(amount);
r.unit.set(unit == null ? "" : unit);
return r;
}
public FormalIngredient toFormalIngredient() {
Ingredient ing = backingIngredient;
if (ing == null) {
ing = new Ingredient(getName(), 0.0, 0.0, 0.0);
} else {
ing.setName(getName());
}
return new FormalIngredient(ing, getAmount(), getUnit());
}
public StringProperty nameProperty() {
return name;
}
public DoubleProperty amountProperty() {
return amount;
}
public StringProperty unitProperty() {
return unit;
}
public String getName() {
return name.get();
}
public void setName(String name) {
this.name.set(name == null ? "" : name);
}
public double getAmount() {
return amount.get();
}
public void setAmount(double amount) {
this.amount.set(amount);
}
public String getUnit() {
return unit.get();
}
public void setUnit(String unit) {
this.unit.set(unit == null ? "" : unit);
}
}
}

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,119 @@
package client.scenes.shopping;
import client.UI;
import client.service.ShoppingListService;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.PrintExportService;
import com.google.inject.Inject;
import commons.FormalIngredient;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ListView;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.util.Pair;
import java.io.File;
import java.util.Optional;
public class ShoppingListCtrl implements LocaleAware {
ShoppingListService shopping;
LocaleManager localeManager;
@FXML
private ListView<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.setItems(this.shopping.getViewModel().getListItems());
}
private void refreshList() {
this.shoppingListView.getItems().setAll(
this.shopping.getItems()
);
}
public void handleAddItem(ActionEvent actionEvent) {
Stage stage = new Stage();
Pair<ShoppingListNewItemPromptCtrl, Parent> root = UI.getFXML().load(ShoppingListNewItemPromptCtrl.class,
"client", "scenes", "shopping", "ShoppingListItemAddModal.fxml");
root.getKey().setNewValueConsumer(fi -> {
this.shopping.putIngredient(fi);
});
stage.setScene(new Scene(root.getValue()));
stage.setTitle("My modal window");
stage.initModality(Modality.WINDOW_MODAL);
stage.initOwner(
((Node)actionEvent.getSource()).getScene().getWindow() );
stage.showAndWait();
}
public void handleRemoveItem(ActionEvent actionEvent) {
var x = this.shoppingListView.getSelectionModel().getSelectedItem();
this.shopping.getItems().remove(x);
refreshList();
}
public void handleReset(ActionEvent actionEvent) {
shopping.reset();
}
public void handlePrint(ActionEvent actionEvent) {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save Shopping List");
fileChooser.getExtensionFilters().add(
new FileChooser.ExtensionFilter("Text Files", "*.txt")
);
fileChooser.setInitialFileName("shopping-list.txt");
File file = fileChooser.showSaveDialog(
((Node) actionEvent.getSource()).getScene().getWindow()
);
if (file == null) {
return;
}
try {
PrintExportService.exportToFile(
shopping.makePrintable(),
file.getParentFile().toPath(),
file.getName()
);
} catch (Exception e) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Error");
alert.setHeaderText("Failed to save shopping list");
alert.setContentText(e.getMessage());
alert.showAndWait();
}
}
}

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,124 @@
package client.service;
import com.google.inject.Inject;
import commons.FormalIngredient;
import commons.Recipe;
import javafx.util.Pair;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
public class ShoppingListServiceImpl extends ShoppingListService {
private final Logger logger = Logger.getLogger(ShoppingListServiceImpl.class.getName());
@Inject
public ShoppingListServiceImpl(
ShoppingListViewModel model
) {
super(model);
}
@Override
public void putIngredient(FormalIngredient ingredient) {
getViewModel().getListItems().add(new Pair<>(ingredient, Optional.empty()));
}
@Override
public void putIngredient(FormalIngredient ingredient, Recipe recipe) {
Pair<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) {
if (name == null || name.isBlank()) {
return;
}
var ingredient = new commons.Ingredient(name.trim(), 0.0, 0.0, 0.0);
var fi = new commons.FormalIngredient(ingredient, 0.0, "");
getViewModel().getListItems().add(new Pair<>(fi, Optional.empty()));
}
@Override
public FormalIngredient purgeIngredient(Long id) {
if (id == null) {
return null;
}
for (var item : getViewModel().getListItems()) {
FormalIngredient fi = item.getKey();
if (fi != null && fi.getId() != null && fi.getId().equals(id)) {
getViewModel().getListItems().remove(item);
return fi;
}
}
return null;
}
@Override
public FormalIngredient purgeIngredient(String ingredientName) {
if (ingredientName == null) {
return null;
}
for (var item : getViewModel().getListItems()) {
FormalIngredient fi = item.getKey();
if (fi != null
&& fi.getIngredient() != null
&& ingredientName.equals(fi.getIngredient().getName())) {
getViewModel().getListItems().remove(item);
return fi;
}
}
return null;
}
@Override
public void reset() {
getViewModel().getListItems().clear();
}
@Override
public List<Pair<FormalIngredient, Optional<String>>> getItems() {
return getViewModel().getListItems();
}
@Override
public String makePrintable() {
StringBuilder sb = new StringBuilder();
for (var item : getViewModel().getListItems()) {
FormalIngredient ingredient = item.getKey();
Optional<String> source = item.getValue();
if (ingredient == null || ingredient.getIngredient() == null) {
continue;
}
sb.append(ingredient.getIngredient().getName());
if (ingredient.getAmount() > 0) {
sb.append(" - ")
.append(ingredient.getAmount());
if (ingredient.getUnitSuffix() != null && !ingredient.getUnitSuffix().isBlank()) {
sb.append(ingredient.getUnitSuffix());
}
}
source.ifPresent(recipe ->
sb.append(" (").append(recipe).append(")")
);
sb.append(System.lineSeparator());
}
return sb.toString();
}
}

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

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.chart.PieChart?>
<PieChart fx:controller="client.scenes.nutrition.NutritionPieChartCtrl" fx:id="pieChart" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" />

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>
<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,5 +42,11 @@
<!-- Preparation -->
<fx:include source="RecipeStepList.fxml" fx:id="stepList"
VBox.vgrow="ALWAYS" maxWidth="Infinity" />
<HBox spacing="20">
<VBox spacing="0">
<Label fx:id="inferredServeSizeLabel" />
<Label fx:id="inferredKcalLabel" />
</VBox>
<fx:include fx:id="pieChart" source="../nutrition/NutritionPieChart.fxml" />
</HBox>
</VBox>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane xmlns="http://javafx.com/javafx/25"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="client.scenes.shopping.AddOverviewCtrl"
prefHeight="420.0" prefWidth="720.0">
<VBox spacing="10.0" AnchorPane.topAnchor="10.0" AnchorPane.leftAnchor="10.0"
AnchorPane.rightAnchor="10.0" AnchorPane.bottomAnchor="10.0">
<Label text="Review ingredients before adding" />
<TableView fx:id="overviewTable" editable="true" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="nameColumn" text="Ingredient" prefWidth="360.0"/>
<TableColumn fx:id="amountColumn" text="Amount" prefWidth="140.0"/>
<TableColumn fx:id="unitColumn" text="Unit" prefWidth="140.0"/>
</columns>
</TableView>
<HBox spacing="10.0">
<Button text="Add item" onAction="#handleAddRow"/>
<Button text="Remove selected" onAction="#handleRemoveSelected"/>
<Pane HBox.hgrow="ALWAYS"/>
<Button text="Cancel" onAction="#handleCancel"/>
<Button text="Confirm &amp; Add" onAction="#handleConfirm"/>
</HBox>
</VBox>
</AnchorPane>

View file

@ -0,0 +1,25 @@
<?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>
<Button onAction="#handlePrint">Print</Button>
<Button onAction="#handleReset">Reset</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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -38,8 +38,14 @@ menu.search=Search...
menu.label.selected-langs=Languages
menu.nutrition.carbs=Carbohydrates
menu.nutrition.protein=Protein
menu.nutrition.fat=Fat
lang.en.display=English
lang.nl.display=Dutch
lang.pl.display=Polish
lang.tok.display=toki pona
lang.tr.display=Turkish
lang.tr.display=T\u00FCrk\u00E7e
lang.zht.display=中文(台灣)
lang.zhc.display=中文(中国大陆)

View file

@ -38,8 +38,16 @@ menu.search=Search...
menu.label.selected-langs=Languages
menu.shopping.title=Shopping list
menu.nutrition.carbs=Carbohydrates
menu.nutrition.protein=Protein
menu.nutrition.fat=Fat
lang.en.display=English
lang.nl.display=Dutch
lang.pl.display=Polish
lang.tok.display=toki pona
lang.tr.display=Turkish
lang.tr.display=T\u00FCrk\u00E7e
lang.zht.display=中文(台灣)
lang.zhc.display=中文(中国大陆)

View file

@ -36,9 +36,17 @@ menu.button.close=Sluiten
menu.label.selected-langs=Talen
menu.shopping.title=Boodschappenlijst
menu.nutrition.carbs=Koolhydraten
menu.nutrition.protein=Eiwitten
menu.nutrition.fat=Vetten
menu.search=Zoeken...
lang.en.display=Engels
lang.nl.display=Nederlands
lang.pl.display=Pools
lang.tok.display=toki pona
lang.tr.display=Turks
lang.tr.display=T\u00FCrk\u00E7e
lang.zht.display=中文(台灣)
lang.zhc.display=中文(中国大陆)

View file

@ -38,8 +38,16 @@ menu.search=Szukaj...
menu.label.selected-langs=Języki
menu.shopping.title=Lista zakup闚
menu.nutrition.carbs=W?glowodany
menu.nutrition.protein=Bia?ka
menu.nutrition.fat=T?uszcze
lang.en.display=Inglisz
lang.nl.display=Holenderski
lang.pl.display=Polski
lang.tok.display=toki pona
lang.tr.display=Turecki
lang.tr.display=T\u00FCrk\u00E7e
lang.zht.display=銝剜<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
lang.zhc.display=銝剜<EFBFBD><EFBFBD><EFBFBD>賢之<EFBFBD><EFBFBD><EFBFBD>

View file

@ -38,8 +38,15 @@ menu.search=o alasa
menu.label.selected-langs=toki wile
menu.shopping.title=ijo wile mani mute
menu.nutrition.carbs=kipisi moku suwi
menu.nutrition.protein=kipisi moku walo
menu.nutrition.fat=kipisi moku suli
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
lang.zht.display=toki Sonko (tan pi tenpo pini)

View file

@ -38,7 +38,16 @@ 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
menu.nutrition.carbs=Karbonhidratlar
menu.nutrition.protein=Protein
menu.nutrition.fat=Ya?
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=中文(中国大陆)

View file

@ -0,0 +1,42 @@
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=语言
menu.nutrition.carbs=?????
menu.nutrition.protein=???
menu.nutrition.fat=??
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=中文(中国大陆)

View file

@ -0,0 +1,42 @@
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=語言
menu.nutrition.carbs=?????
menu.nutrition.protein=???
menu.nutrition.fat=??
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=中文(中国大陆)

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

@ -2,6 +2,7 @@ package commons;
import jakarta.persistence.Entity;
import java.text.DecimalFormat;
import java.util.Objects;
import java.util.Optional;
@ -15,6 +16,13 @@ public class FormalIngredient extends RecipeIngredient implements Scalable<Forma
private double amount;
private String unitSuffix;
private static final int tbspToPoundConvert = 32;
private static final int tbspToCupConvert = 16;
private static final int tbspToOunceConvert = 2;
private static final int OunceToPoundConvert = 16;
private static final DecimalFormat numberFormat = new DecimalFormat("#.00");
public double getAmount() {
return amount;
}
@ -31,6 +39,8 @@ public class FormalIngredient extends RecipeIngredient implements Scalable<Forma
this.unitSuffix = unitSuffix;
}
public FormalIngredient(Long id, Ingredient ingredient, double amount, String unitSuffix) {
// For testing
super(id, ingredient);
@ -55,8 +65,54 @@ public class FormalIngredient extends RecipeIngredient implements Scalable<Forma
}
return amount * unit.get().conversionFactor;
}
public String normalisedUnit(){
Optional<Unit> unit = Unit.fromString(unitSuffix);
if (unit.isEmpty() || !unit.get().isFormal() || unit.get().conversionFactor <= 0) {
return numberFormat.format(amount) + unitSuffix;
}
Unit currentUnit = unit.get();
double baseAmount = amount * currentUnit.conversionFactor;
switch (currentUnit){
case GRAMME -> {
if(baseAmount >= Unit.TONNE.conversionFactor){
return numberFormat.format(baseAmount /Unit.TONNE.conversionFactor) + Unit.TONNE.suffix;
}if(baseAmount >=Unit.KILOGRAMME.conversionFactor) {
return numberFormat.format(baseAmount / Unit.KILOGRAMME.conversionFactor) + Unit.KILOGRAMME.suffix;
}
}
case MILLILITRE -> {
if (baseAmount >= Unit.LITRE.conversionFactor) {
return numberFormat.format(baseAmount /Unit.LITRE.conversionFactor) + Unit.LITRE.suffix;
}
}
case TABLESPOON -> {
if(amount>=tbspToPoundConvert){
return numberFormat.format(amount/tbspToPoundConvert) + Unit.POUND.suffix;
}
if(amount>=tbspToCupConvert){
return numberFormat.format(amount /tbspToCupConvert) + Unit.CUP.suffix;
}
if(amount>=tbspToOunceConvert){
return numberFormat.format(amount /tbspToOunceConvert) + Unit.OUNCE.suffix;
}
}
case OUNCE -> {
if (baseAmount >= OunceToPoundConvert) {
return numberFormat.format(amount / OunceToPoundConvert) + Unit.POUND.suffix;
}
}
}
return numberFormat.format(amount) + currentUnit.suffix;
}
public String toString() {
return amount + unitSuffix + " of " + ingredient.name;
return normalisedUnit()+ " of " + ingredient.name;
}
@Override

View file

@ -203,7 +203,10 @@ public class Recipe {
final double PER = 100; // Gram
return
this.ingredients.stream().mapToDouble(RecipeIngredient::getKcal).sum() /
this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum() * PER;
weight() * PER;
}
public double weight() {
return this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum();
}
}

View file

@ -0,0 +1,58 @@
package commons;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class FormalIngredientTest {
@Test
void normaliseGramsToTest(){
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
FormalIngredient toKg = new FormalIngredient(ingredient,1_000,"g");
FormalIngredient toTonne = new FormalIngredient(ingredient,1_000_000,"g");
assertEquals(toKg.normalisedUnit(),"1.00kg");
assertEquals(toTonne.normalisedUnit(),"1.00t");
}
@Test
void normaliseMillilitresToLitresTest(){
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
FormalIngredient toKg = new FormalIngredient(ingredient,1_000,"ml");
assertEquals(toKg.normalisedUnit(),"1.00l");
}
@Test
void normaliseOunceToPoundTest(){
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
FormalIngredient toPound = new FormalIngredient(ingredient,16,"oz");
assertEquals(toPound.normalisedUnit(),"1.00lb");
}
@Test
void normaliseTablespoonToTest(){
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
FormalIngredient toCup = new FormalIngredient(ingredient,16,"tbsp");
FormalIngredient toPound = new FormalIngredient(ingredient,32,"tbsp");
FormalIngredient toOunce = new FormalIngredient(ingredient,2,"tbsp");
assertEquals(toCup.normalisedUnit(),"1.00cup(s)");
assertEquals(toPound.normalisedUnit(),"1.00lb");
assertEquals(toOunce.normalisedUnit(),"1.00oz");
}
@Test
void noNormaliseTest(){
Ingredient ingredient = new Ingredient("Bread", 1, 2, 3);
FormalIngredient informal = new FormalIngredient(ingredient,10,"<NONE>");
FormalIngredient toSmall = new FormalIngredient(ingredient,10,"g");
assertEquals(informal.normalisedUnit(),"10.00<NONE>");
assertEquals(toSmall.normalisedUnit(),"10.00g");
}
}

67
docs/agenda/agenda-09.md Normal file
View file

@ -0,0 +1,67 @@
# Week 9 meeting agenda
| Key | Value |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| Date | Jan 23rd 2026 |
| Time | 16:45 |
| Location | DW PC Hall 2 |
| Chair | Natalia Cholewa |
| Minute Taker | Steven Liu |
| Attendees | Natalia Cholewa, Oskar Rasieński, Rithvik Sriram, Aysegul Aydinlik, Steven Liu, Mei Chang van der Werff |
## Table of contents
### Opening
1. (1 min) Introduction by chair
> 4:53 PM Begin meeting
> Topics:
> Go through everything done
> Assign the last few tasks and clean up
> A bit of quality assurance
2. (1 min) Any additions to the agenda?
> None so far.
3. (1-2 min) TA announcements?
> Try to finish before Sunday due to concerns of GitLab performance.
> No need to explain everything, but mention meaningful additions in the README (refer to Brightspace announcement)
4. (1 min) Buddycheck reminder - January 30th
> remember to stick to the AID model when writing the Buddycheck.
### Final check-ups
5. (15 min) Go over all everything we've done and check how close we are to completion with every feature.
> We are basically all done except a few changes.
> Still some work on languages. PR to be made.
> Further contributions - try to add testing on client side if we have free time
* (5 min) Progress check regarding this week
> Rithvik - improved client features by providing interface to end user if server not found on URL
> Oskar - Translation and various bugfixes
> Mei - Highlight for favouriting, alphabetic ordering for ingredients, normalizing units, server tests
> Ayse - Turkish i18n, ingredientctrl tests, multiple searching, rest of 4.5 aka. printing etc.
> Steven - crud buttons for the shopping list, adding chinese, various bugfixes
> Nat - scaffolding for shopping list, toki pona
* (3 min) Showcase of the (almost final) version
> Implemented features look fully completed. Some finer parts may need revision
### Planning
6. (5 min) Assign the very last features to implement today.
* This can be as simple as going over them as well.
* Code freeze is **Sunday**. Make sure every issue is closed, every merge request is resolved and every other branch is deleted.
> 1. lang, pie chart constantly refreshes, it should refresh less
> 2. check stale TODOs and implement real TODOs otherwise
> 3. fix janky ingredient list
> 4. should have everything done today, worst case tmr morning.
> 5. housekeeping on the git repo (remove branches, clean issues, milestones, etc.)
### Closing
7. (2 min) Questions? (None)
8. (2 min) Summarize everything
> 98% done, need to fix tiny bugs, and that's it
9. (1 min) TA remarks
Closed at 5:09 PM, total elapsed: 16 minutes.
Estimated meeting time: ~25-30 minutes

60
docs/feedback/week-08.md Normal file
View file

@ -0,0 +1,60 @@
# Meeting feedback
**Quick Summary Scale**
Insufficient/Sufficient/Good/Excellent
#### Agenda
Feedback: **Excellent**
- The agenda was added on time to the repository.
- The agenda follows the required format.
- The individual points of discussion are clear and detailed.
- I liked that you kept the separate sections template for the agenda.
#### Performance of the Previous Minute Taker
Feedback: **Excellent**
- The notes have been merged to the agenda file.
- The notes are plentiful, detailed and clear.
- They contain concrete and realistic agreements.
- Everyone was assigned a task.
#### Chair performance
Feedback: **Excellent**
- You worked through your agenda well and carried the meeting forward.
- You engaged everyone into the conversation.
- Time estimates seemed accurate.
- I liked that you started with a brief summary of all feedbacks.
- You listened to everyone and also ensured that they agree with you.
#### Attitude & Relation
Feedback: **Excellent**
- Everyone took good ownership of the meeting and participated equally.
- Everyone was active and listened to all ideas.
- The atmosphere was practical and constructive.
- Everyone provided feedback on the agreements.
#### Potentially Shippable Product
Feedback: **Excellent**
- You presented the current shippable product during the meeting.
- There was great progress compared to last week.
- You are on a good track to create a fully working application by the end.
#### Work Contribution/Distribution in the Team
Feedback: **Excellent**
- You discussed the action points of the last week and your progress on them.
- Everyone reached their goal.
- Everyone has contributed to the project.
- I liked that you distributed all remaining tasks and set an internal deadline for the project.

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