refactor: newest delivery from main

This commit is contained in:
Zhongheng Liu 2025-12-19 23:54:45 +02:00
commit 00e4bb03f4
Signed by: steven
GPG key ID: F69B980899C1C09D
32 changed files with 722 additions and 881 deletions

View file

@ -34,21 +34,25 @@
<artifactId>jersey-client</artifactId>
<version>${version.jersey}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<version>${version.jersey}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<version>${version.jersey}</version>
</dependency>
<dependency>
<groupId>jakarta.activation</groupId>
<artifactId>jakarta.activation-api</artifactId>
<version>2.1.3</version>
</dependency>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
@ -61,11 +65,13 @@
<artifactId>javafx-fxml</artifactId>
<version>${version.jfx}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${version.jfx}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
@ -91,6 +97,27 @@
<version>${version.mockito}</version>
<scope>test</scope>
</dependency>
<!-- websockets -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
<version>6.2.12</version>
<scope>compile</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.glassfish.tyrus.bundles/tyrus-standalone-client -->
<dependency>
<groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>6.2.12</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>

View file

@ -18,13 +18,18 @@ package client;
import client.scenes.FoodpalApplicationCtrl;
import client.scenes.recipe.IngredientListCtrl;
import client.scenes.recipe.RecipeStepListCtrl;
import client.utils.ConfigService;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
import client.utils.WebSocketUtils;
import com.google.inject.Binder;
import com.google.inject.Module;
import com.google.inject.Scopes;
import client.scenes.MainCtrl;
import java.nio.file.Path;
public class MyModule implements Module {
@Override
@ -33,6 +38,12 @@ public class MyModule implements Module {
binder.bind(FoodpalApplicationCtrl.class).in(Scopes.SINGLETON);
binder.bind(IngredientListCtrl.class).in(Scopes.SINGLETON);
binder.bind(RecipeStepListCtrl.class).in(Scopes.SINGLETON);
binder.bind(LocaleManager.class).in(Scopes.SINGLETON);
binder.bind(ServerUtils.class).in(Scopes.SINGLETON);
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
binder.bind(ConfigService.class).toInstance(new ConfigService(Path.of("config.json")));
}
}

View file

@ -9,17 +9,22 @@ import client.exception.UpdateException;
import client.scenes.recipe.IngredientListCtrl;
import client.scenes.recipe.RecipeStepListCtrl;
import client.utils.Config;
import client.utils.ConfigService;
import client.utils.DefaultRecipeFactory;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
import client.utils.WebSocketUtils;
import commons.Recipe;
import commons.ws.Topics;
import commons.ws.messages.Message;
import jakarta.inject.Inject;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
@ -31,6 +36,7 @@ import javafx.scene.text.Font;
public class FoodpalApplicationCtrl implements LocaleAware {
private final ServerUtils server;
private final WebSocketUtils webSocketUtils;
private final LocaleManager localeManager;
private final IngredientListCtrl ingredientListCtrl;
private final RecipeStepListCtrl stepListCtrl;
@ -41,10 +47,9 @@ public class FoodpalApplicationCtrl implements LocaleAware {
// everything in the left lane
@FXML
public Label recipesLabel;
public ComboBox<String> langSelectMenu;
@FXML
private ListView<Recipe> recipeList;
public ListView<Recipe> recipeList;
@FXML
private Button addRecipeButton;
@ -55,6 +60,13 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@FXML
public Button cloneRecipeButton;
@FXML
private Button favouriteButton;
private Config config;
private final ConfigService configService;
// === CENTER: RECIPE DETAILS ===
@FXML
@ -69,14 +81,20 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@Inject
public FoodpalApplicationCtrl(
ServerUtils server,
WebSocketUtils webSocketUtils,
LocaleManager localeManager,
IngredientListCtrl ingredientListCtrl,
RecipeStepListCtrl stepListCtrl
RecipeStepListCtrl stepListCtrl,
ConfigService configService
) {
this.server = server;
this.webSocketUtils = webSocketUtils;
this.localeManager = localeManager;
this.ingredientListCtrl = ingredientListCtrl;
this.stepListCtrl = stepListCtrl;
this.configService = configService;
initializeWebSocket();
}
private void initRecipeList() {
// Show recipe name in the list
@ -87,10 +105,12 @@ public class FoodpalApplicationCtrl implements LocaleAware {
setText(empty || item == null ? "" : item.getName());
}
});
// When your selection changes, update details in the panel
recipeList.getSelectionModel().selectedItemProperty().addListener(
(obs, oldRecipe, newRecipe) -> showRecipeDetails(newRecipe)
(obs, oldRecipe, newRecipe) -> {
showRecipeDetails(newRecipe);
updateFavouriteButton(newRecipe);
}
);
// Double-click to go to detail screen
@ -101,6 +121,27 @@ public class FoodpalApplicationCtrl implements LocaleAware {
}
});
}
private void initializeWebSocket() {
webSocketUtils.connect(() -> {
webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> {
Platform.runLater(() -> {
Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem();
refresh(); // refresh the left list
if (selectedRecipe == null) {
return;
}
// select last selected recipe if it still exists, first otherwise (done by refresh())
Recipe recipeInList = recipeList.getItems().stream()
.filter(r -> r.getId().equals(selectedRecipe.getId()))
.findFirst()
.orElse(null);
showRecipeDetails(recipeInList);
}); // runLater as it's on another non-FX thread.
});
});
}
private void initStepsIngredientsList() {
// Initialize callback for ingredient list updates
this.ingredientListCtrl.setUpdateCallback(newList -> {
@ -134,12 +175,14 @@ public class FoodpalApplicationCtrl implements LocaleAware {
}
@Override
public void initializeComponents() {
// TODO Reduce code duplication??
config = configService.getConfig();
initStepsIngredientsList();
initRecipeList();
refresh();
updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem());
}
private void showName(String name) {
final int NAME_FONT_SIZE = 20;
editableTitleArea.getChildren().clear();
@ -147,6 +190,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
nameLabel.setFont(new Font("System Bold", NAME_FONT_SIZE));
editableTitleArea.getChildren().add(nameLabel);
}
@Override
public void updateText() {
addRecipeButton.setText(getLocaleString("menu.button.add.recipe"));
@ -158,13 +202,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
printRecipeButton.setText(getLocaleString("menu.button.print"));
recipesLabel.setText(getLocaleString("menu.label.recipes"));
// TODO propagate ResourceBundle lang changes to nested controller
// ingredientsLabel.setText(getLocaleString("menu.label.ingredients"));
// preparationLabel.setText(getLocaleString("menu.label.preparation"));
// addIngredientButton.setText(getLocaleString("menu.button.add.ingredient"));
// addPreparationStepButton.setText(getLocaleString("menu.button.add.step"));
}
@Override
@ -200,11 +237,13 @@ public class FoodpalApplicationCtrl implements LocaleAware {
}
detailsScreen.visibleProperty().set(!recipes.isEmpty());
}
private void printError(String msg) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setContentText(msg);
alert.showAndWait();
}
/**
* Adds a recipe, by providing a default name "Untitled recipe (n)".
* The UX flow is now:
@ -277,7 +316,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
try {
server.updateRecipe(selected);
refresh();
recipeList.getSelectionModel().select(selected);
//recipeList.getSelectionModel().select(selected);
} catch (IOException | InterruptedException e) {
// throw a nice blanket UpdateException
throw new UpdateException("Error occurred when updating recipe name!");
@ -287,7 +326,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
editableTitleArea.getChildren().add(edit);
edit.requestFocus();
}
@FXML
private void makePrintable() {
System.out.println("Recipe printed");
@ -307,6 +345,39 @@ public class FoodpalApplicationCtrl implements LocaleAware {
recipeList.getSelectionModel().select(cloned);
}
private void updateFavouriteButton(Recipe recipe) {
if (recipe == null) {
favouriteButton.setDisable(true);
favouriteButton.setText("");
return;
}
favouriteButton.setDisable(false);
favouriteButton.setText(
config.isFavourite(recipe.getId()) ? "" : ""
);
}
@FXML
private void toggleFavourite() {
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
}
long id = selected.getId();
if (config.isFavourite(id)) {
config.removeFavourite(id);
} else {
config.addFavourite(id);
}
configService.save();
updateFavouriteButton(selected);
}
}

View file

@ -0,0 +1,38 @@
package client.scenes.nutrition;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import com.google.inject.Inject;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
public class NutritionDetailsCtrl implements LocaleAware {
private final LocaleManager manager;
public Label ingredientName;
public Label fatInputLabel;
public Label proteinInputLabel;
public Label carbInputLabel;
public Label estimatedKcalLabel;
public Label usageLabel;
public TextField fatInputElement;
public TextField proteinInputElement;
public TextField carbInputElement;
@Inject
public NutritionDetailsCtrl(
LocaleManager manager
) {
this.manager = manager;
}
@Override
public void updateText() {
}
@Override
public LocaleManager getLocaleManager() {
return manager;
}
}

View file

@ -0,0 +1,68 @@
package client.scenes.nutrition;
import client.scenes.FoodpalApplicationCtrl;
import com.google.inject.Inject;
import commons.Recipe;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.control.ListView;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class NutritionViewCtrl {
private ObservableList<Recipe> recipes;
private HashMap<String, Integer> ingredientStats;
public ListView<String> nutritionIngredientsView;
private final NutritionDetailsCtrl nutritionDetailsCtrl;
// TODO into Ingredient class definition
/**
* Comedically verbose function to count unique appearances of an ingredient by name in each recipe.
* For each recipe:
* 1. Collect unique ingredients that appeared in that recipe.
* 2. For each unique ingredient in said recipe:
* 1. Initialize the appearance for that ingredient to 0.
* 2. For each recipe in list:
* 1. If the name of the ingredient exists in the recipe list, increment the statistic by 1.
* 2. Else maintain the same value for that statistic.
* @param recipeList
*/
private void updateIngredientStats(
List<Recipe> recipeList
) {
recipeList.forEach(recipe -> {
Set<String> uniqueIngredients = new HashSet<>(recipe.getIngredients());
nutritionIngredientsView.getItems().setAll(uniqueIngredients);
uniqueIngredients.forEach(ingredient -> {
ingredientStats.put(ingredient, 0);
recipeList.forEach(r ->
ingredientStats.put(
ingredient,
ingredientStats.get(ingredient) + (
(r.getIngredients().contains(ingredient))
? 1 : 0
)
)
);
});
});
}
@Inject
public NutritionViewCtrl(
FoodpalApplicationCtrl foodpalApplicationCtrl,
NutritionDetailsCtrl nutritionDetailsCtrl
) {
this.recipes = foodpalApplicationCtrl.recipeList.getItems();
this.recipes.addListener((ListChangeListener<? super Recipe>) _ -> {
updateIngredientStats(this.recipes);
});
this.nutritionDetailsCtrl = nutritionDetailsCtrl;
this.nutritionIngredientsView.selectionModelProperty().addListener((observable, oldValue, newValue) -> {
});
}
}

View file

@ -12,7 +12,8 @@ public class Config {
private List<Long> favourites = new ArrayList<>();
private List<String> shoppingList = new ArrayList<>();
public Config(){}
public Config() {
}
public String getLanguage() {
return language;
@ -22,10 +23,6 @@ public class Config {
return shoppingList;
}
public List<Long> getFavourites() {
return favourites;
}
public String getServerUrl() {
return serverUrl;
}
@ -45,4 +42,29 @@ public class Config {
public void setShoppingList(List<String> shoppingList) {
this.shoppingList = shoppingList;
}
// favourite helper
public List<Long> getFavourites() {
if (favourites == null) {
favourites = new ArrayList<>();
}
return favourites;
}
// to avoid null pointers.
public boolean isFavourite(long recipeId) {
return getFavourites().contains(recipeId);
}
public void addFavourite(long recipeId) {
if (!getFavourites().contains(recipeId)) {
getFavourites().add(recipeId);
}
}
public void removeFavourite(long recipeId) {
getFavourites().remove(recipeId);
}
}

View file

@ -1,72 +0,0 @@
/*
* Copyright 2021 Delft University of Technology
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package client.utils;
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
import java.net.ConnectException;
import java.util.List;
import jakarta.ws.rs.core.GenericType;
import org.glassfish.jersey.client.ClientConfig;
import commons.Quote;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.Entity;
public class ServerUtilsExample {
private static final String SERVER = "http://localhost:8080/";
// public void getQuotesTheHardWay() throws IOException, URISyntaxException {
// var url = new URI("http://localhost:8080/api/quotes").toURL();
// var is = url.openConnection().getInputStream();
// var br = new BufferedReader(new InputStreamReader(is));
// String line;
// while ((line = br.readLine()) != null) {
// System.out.println(line);
// }
// }
public List<Quote> getQuotes() {
return ClientBuilder.newClient(new ClientConfig()) //
.target(SERVER).path("api/quotes") //
.request(APPLICATION_JSON) //
.get(new GenericType<List<Quote>>() {});
}
public Quote addQuote(Quote quote) {
return ClientBuilder.newClient(new ClientConfig()) //
.target(SERVER).path("api/quotes") //
.request(APPLICATION_JSON) //
.post(Entity.entity(quote, APPLICATION_JSON), Quote.class);
}
public boolean isServerAvailable() {
try {
ClientBuilder.newClient(new ClientConfig()) //
.target(SERVER) //
.request(APPLICATION_JSON) //
.get();
} catch (ProcessingException e) {
if (e.getCause() instanceof ConnectException) {
return false;
}
}
return true;
}
}

View file

@ -0,0 +1,111 @@
package client.utils;
import commons.ws.messages.Message;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompHeaders;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import java.lang.reflect.Type;
import java.util.function.Consumer;
import javax.annotation.Nullable;
import java.util.concurrent.CompletableFuture;
public class WebSocketUtils {
private static final String WS_URL = "ws://localhost:8080/updates";
private WebSocketStompClient stompClient;
private StompSession stompSession;
/**
* Connect to the websocket server.
* @param onConnected OnConnected callback
*/
public void connect(@Nullable Runnable onConnected) {
StandardWebSocketClient webSocketClient = new StandardWebSocketClient(); // Create WS Client
stompClient = new WebSocketStompClient(webSocketClient);
stompClient.setMessageConverter(new MappingJackson2MessageConverter()); // Use jackson
CompletableFuture<StompSession> sessionFuture = stompClient.connectAsync(
WS_URL,
new StompSessionHandlerAdapter() {
@Override
public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
stompSession = session;
System.out.println("WebSocket connected: " + session.getSessionId());
}
@Override
public void handleException(StompSession session, @Nullable StompCommand command,
StompHeaders headers, byte[] payload,
Throwable exception) {
System.err.println("STOMP error: " + exception.getMessage());
exception.printStackTrace();
}
@Override
public void handleTransportError(StompSession session, Throwable exception) {
System.err.println("STOMP transport error: " + exception.getMessage());
exception.printStackTrace();
}
}
);
sessionFuture.thenAccept(session -> {
stompSession = session;
System.out.println("Connection successful, session ready");
if (onConnected != null) {
onConnected.run();
}
}).exceptionally(throwable -> {
System.err.println("Failed to connect: " + throwable.getMessage());
throwable.printStackTrace();
return null;
});
}
/**
* Subscribe to a topic.
* @param destination Destination to subscribe to, for example: /subscribe/recipe
* @param messageHandler Handler for received messages
*/
public void subscribe(String destination, Consumer<Message> messageHandler) {
if (stompSession == null || !stompSession.isConnected()) {
System.err.println("Cannot subscribe - not connected");
return;
}
stompSession.subscribe(destination, new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return Message.class;
}
@Override
public void handleFrame(StompHeaders headers, @Nullable Object payload) {
Message message = (Message) payload;
messageHandler.accept(message);
}
});
System.out.println("Subscribed to: " + destination);
}
public void disconnect() {
if (stompSession != null && stompSession.isConnected()) {
stompSession.disconnect();
}
if (stompClient != null) {
stompClient.stop();
}
}
public boolean isConnected() {
return stompSession != null && stompSession.isConnected();
}
}

View file

@ -69,6 +69,7 @@
<Button fx:id="editRecipeTitleButton" onAction="#editRecipeTitle" text="Edit" />
<Button fx:id="removeRecipeButton2" mnemonicParsing="false" onAction="#removeSelectedRecipe" text="Remove Recipe" />
<Button fx:id="printRecipeButton" mnemonicParsing="false" onAction="#makePrintable" text="Print Recipe" />
<Button fx:id="favouriteButton" onAction="#toggleFavourite" text="☆" />
</HBox>
<!-- Ingredients -->

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.shape.Line?>
<AnchorPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="client.scenes.nutrition.NutritionDetailsCtrl"
prefHeight="400.0" prefWidth="600.0">
<VBox visible="false">
<Label fx:id="ingredientName" />
<HBox>
<Label fx:id="fatInputLabel">Fat: </Label>
<TextField fx:id="fatInputElement" />
</HBox>
<HBox>
<Label fx:id="proteinInputLabel">Protein: </Label>
<TextField fx:id="proteinInputElement" />
</HBox>
<HBox>
<Label fx:id="carbInputLabel">Carbohydrates: </Label>
<TextField fx:id="carbInputElement" />
</HBox>
<Label fx:id="estimatedKcalLabel">Estimated: 0kcal</Label>
<Label fx:id="usageLabel">Not used in any recipes</Label>
</VBox>
</AnchorPane>

View file

@ -0,0 +1,19 @@
<?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.nutrition.NutritionViewCtrl"
prefHeight="400.0" prefWidth="600.0">
<SplitPane>
<ListView fx:id="nutritionIngredientsView" />
<AnchorPane>
<fx:include source="NutritionDetails.fxml" />
</AnchorPane>
</SplitPane>
</AnchorPane>