Compare 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 2288 additions and 227 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,9 +28,14 @@ public class UI extends Application {
var serverUtils = INJECTOR.getInstance(ServerUtils.class);
if (!serverUtils.isServerAvailable()) {
var msg = "Server needs to be started before the client, but it does not seem to be available. Shutting down.";
System.err.println(msg);
return;
var connectionHandler = INJECTOR.getInstance(ServerConnectionDialogCtrl.class);
boolean serverConnected = connectionHandler.promptForURL();
if(!serverConnected){
var msg = "User Cancelled Server connection. Shutting down";
System.err.print(msg);
return;
}
}
var foodpal = FXML.load(FoodpalApplicationCtrl.class, "client", "scenes", "FoodpalApplication.fxml");

View file

@ -0,0 +1,20 @@
package client.exception;
public class DuplicateIngredientException extends Exception{
private final String ingredientName;
public DuplicateIngredientException(String ingredientName){
super("An ingredient with name " + ingredientName + " already exists, please provide a different name");
this.ingredientName = ingredientName;
}
public DuplicateIngredientException(String ingredientName, String message){
super(message);
this.ingredientName = ingredientName;
}
public String getIngredientName() {
return ingredientName;
}
}

View file

@ -2,17 +2,17 @@ package client.scenes;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import client.UI;
import client.exception.InvalidModificationException;
import client.scenes.nutrition.NutritionViewCtrl;
import client.scenes.recipe.RecipeDetailCtrl;
import client.scenes.shopping.ShoppingListCtrl;
import client.utils.Config;
import client.utils.ConfigService;
import client.utils.DefaultValueFactory;
@ -21,7 +21,6 @@ import client.utils.LocaleManager;
import client.utils.server.ServerUtils;
import client.utils.WebSocketDataService;
import client.utils.WebSocketUtils;
import commons.Ingredient;
import commons.Recipe;
import commons.ws.Topics;
@ -32,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());
}
recipeList.getItems().setAll(view);
// restore selection if possible
if (selectedId != null) {
@ -452,114 +463,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.recipeDetailController.refreshFavouriteButton();
this.recipeDetailController.setVisible(!recipeList.getItems().isEmpty());
}
//Delete Ingredient button click
@FXML
private void handleDeleteIngredient() {
// Get selected ingredient
Ingredient selectedIngredient = ingredientListView.getSelectionModel().getSelectedItem();
if (selectedIngredient == null) {
// Show an error message if no ingredient is selected
showError("No ingredient selected", "Please select an ingredient to delete.");
return;
}
// Check if the ingredient is used in any recipe
checkIngredientUsage(selectedIngredient);
}
// Check if ingredient is used in any recipe before deleting
private void checkIngredientUsage(Ingredient ingredient) {
try {
long usageCount = server.getIngredientUsage(ingredient.getId()); // Check ingredient usage via ServerUtils
if (usageCount > 0) {
// If ingredient is used, show a warning dialog
showWarningDialog(ingredient, usageCount);
} else {
// If not used, delete
deleteIngredient(ingredient);
}
} catch (IOException | InterruptedException e) {
showError("Error", "Failed to check ingredient usage: " + e.getMessage());
}
}
private void deleteIngredient(Ingredient ingredient) {
try {
server.deleteIngredient(ingredient.getId()); // Call ServerUtils to delete the ingredient
showConfirmation("Success", "Ingredient '" + ingredient.getName() + "' has been deleted.");
refreshIngredientList(); // refresh the ingredient list
} catch (IOException | InterruptedException e) {
showError("Error", "Failed to delete ingredient: " + e.getMessage());
}
}
private void showWarningDialog(Ingredient ingredient, long usedInRecipes) {
Alert alert = new Alert(Alert.AlertType.WARNING);
}
private void showError(String title, String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void showConfirmation(String title, String message) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void refreshIngredientList() {
// Refresh
ingredientListView.getItems().clear();
}
@FXML
private void handleAddIngredient() {
//ask the user for the ingredient name
TextInputDialog dialog = new TextInputDialog();
dialog.setTitle("Add Ingredient");
dialog.setHeaderText("Enter the ingredient name:");
dialog.setContentText("Ingredient:");
// Wait for the user to enter a value
Optional<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,24 +172,31 @@ public class RecipeDetailCtrl implements LocaleAware {
// If there is a scale
// Prevents issues from first startup
if (scaleSpinner.getValue() != null) {
if (scaleSpinner.getValue() != null && servingsSpinner.getValue() != null) {
Double scale = scaleSpinner.getValue();
// see impl. creates a scaled context for the recipe such that its non-scaled value is kept as a reference.
this.recipeView = new ScalableRecipeView(recipe, scale);
// TODO i18n
inferredKcalLabel.textProperty().bind(Bindings.createStringBinding(() ->
String.format("Inferred %.1f kcal/100g for this recipe",
Double.isNaN(this.recipeView.scaledKcalProperty().get()) ?
0.0 : this.recipeView.scaledKcalProperty().get())
String.format("Inferred %.1f kcal/100g for this recipe",
Double.isNaN(this.recipeView.scaledKcalProperty().get()) ?
0.0 : this.recipeView.scaledKcalProperty().get())
, this.recipeView.scaledKcalProperty()));
recipeView.servingsProperty().set(servingsSpinner.getValue());
inferredServeSizeLabel.textProperty().bind(Bindings.createStringBinding(
() -> String.format("Inferred size per serving: %.1f g", recipeView.servingSizeProperty().get()),
recipeView.servingSizeProperty()));
// expose the scaled view to list controllers
this.ingredientListController.refetchFromRecipe(this.recipeView.getScaled());
this.stepListController.refetchFromRecipe(this.recipeView.getScaled());
this.pieChartController.setRecipe(recipe);
return;
}
this.ingredientListController.refetchFromRecipe(recipe);
this.stepListController.refetchFromRecipe(recipe);
this.pieChartController.setRecipe(recipe);
}
/**
@ -185,7 +207,7 @@ public class RecipeDetailCtrl implements LocaleAware {
* @param recipeConsumer The helper function to use when updating the recipe -
* how to update it
* @return The created callback to use in a setUpdateCallback or related
* function
* function
*/
private <T> Consumer<T> createUpdateRecipeCallback(BiConsumer<Recipe, T> recipeConsumer) {
return recipes -> {
@ -325,6 +347,7 @@ public class RecipeDetailCtrl implements LocaleAware {
PrintExportService.exportToFile(recipeText, dirPath, filename);
}
}
/**
* Toggles the favourite status of the currently viewed recipe in the
* application configuration and writes the changes to disk.
@ -402,6 +425,47 @@ public class RecipeDetailCtrl implements LocaleAware {
// triggers a UI update each time the spinner changes to a different value.
setCurrentlyViewedRecipe(recipe);
});
langSelector.getItems().addAll("en", "nl", "pl", "tok");
servingsSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1));
servingsSpinner.setEditable(true);
servingsSpinner.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
return;
}
setCurrentlyViewedRecipe(recipe);
});
langSelector.getItems().addAll(Config.languages);
}
@FXML
public void handleAddAllToShoppingList(ActionEvent actionEvent) {
Recipe ingredientSource = (recipeView != null) ? recipeView.getScaled() : recipe;
var ingredients = ingredientSource.getIngredients().stream()
.map(ri -> {
if (ri instanceof FormalIngredient fi) {
return fi;
}
return new FormalIngredient(
ri.getIngredient(),
1,
""
);
})
.toList();
var pair = client.UI.getFXML().load(
client.scenes.shopping.AddOverviewCtrl.class,
"client", "scenes", "shopping", "AddOverview.fxml"
);
var ctrl = pair.getKey();
ctrl.setContext(recipe, ingredients);
Stage stage = new Stage();
stage.setTitle("Add to shopping list");
stage.initModality(Modality.WINDOW_MODAL);
stage.initOwner(((Node) actionEvent.getSource()).getScene().getWindow());
stage.setScene(new Scene(pair.getValue()));
stage.showAndWait();
}
}

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

Some files were not shown because too many files have changed in this diff Show more