Merge branch 'refactor/recipe-details' into 'main'

refactor: put recipe detail controller into its own file

Closes #38

See merge request cse1105/2025-2026/teams/csep-team-76!36
This commit is contained in:
Zhongheng Liu 2026-01-05 16:22:08 +01:00
commit 560a00cd2a
4 changed files with 377 additions and 199 deletions

View file

@ -7,9 +7,7 @@ import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import client.exception.UpdateException;
import client.scenes.recipe.IngredientListCtrl;
import client.scenes.recipe.RecipeStepListCtrl;
import client.scenes.recipe.RecipeDetailCtrl;
import client.utils.Config;
import client.utils.ConfigService;
@ -30,24 +28,18 @@ import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleButton;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
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;
private SearchBarCtrl searchBarCtrl;
@FXML
private RecipeDetailCtrl recipeDetailController;
public VBox detailsScreen;
public HBox editableTitleArea;
@FXML
private SearchBarCtrl searchBarController;
// everything in the left lane
@FXML
@ -65,25 +57,10 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@FXML
public Button cloneRecipeButton;
@FXML
private Button favouriteButton;
// stores the users favourites and load and saves them
private Config config;
private final ConfigService configService;
// === CENTER: RECIPE DETAILS ===
@FXML
private Button editRecipeTitleButton;
@FXML
public Button removeRecipeButton2;
@FXML
public Button printRecipeButton;
@FXML
private ToggleButton favouritesOnlyToggle;
@ -95,20 +72,17 @@ public class FoodpalApplicationCtrl implements LocaleAware {
ServerUtils server,
WebSocketUtils webSocketUtils,
LocaleManager localeManager,
IngredientListCtrl ingredientListCtrl,
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
recipeList.setCellFactory(list -> new ListCell<>() {
@ -126,20 +100,14 @@ public class FoodpalApplicationCtrl implements LocaleAware {
// When your selection changes, update details in the panel
recipeList.getSelectionModel().selectedItemProperty().addListener(
(obs, oldRecipe, newRecipe) -> {
showRecipeDetails(newRecipe);
updateFavouriteButton(newRecipe);
this.recipeDetailController.setCurrentlyViewedRecipe(newRecipe);
}
);
}
@Inject
void setSearchBarCtrl(SearchBarCtrl searchBarCtrl) {
this.searchBarCtrl = searchBarCtrl;
}
private void initializeSearchBar() {
// Refresh on search bar change
this.searchBarCtrl.setOnSearch(recipes -> {
this.searchBarController.setOnSearch(recipes -> {
// Don't lose selection on refresh
Recipe currentlySelected = recipeList.getSelectionModel().getSelectedItem();
int newIndex = -1;
@ -178,42 +146,15 @@ public class FoodpalApplicationCtrl implements LocaleAware {
.findFirst()
.orElse(null);
showRecipeDetails(recipeInList);
this.recipeDetailController.setCurrentlyViewedRecipe(recipeInList);
}); // runLater as it's on another non-FX thread.
});
});
}
private void initStepsIngredientsList() {
// Initialize callback for ingredient list updates
this.ingredientListCtrl.setUpdateCallback(newList -> {
Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem();
if (selectedRecipe == null) { // edge case error for NPE.
throw new NullPointerException("Null recipe whereas ingredients are edited");
}
selectedRecipe.setIngredients(newList);
try { // propagate changes to server
server.updateRecipe(selectedRecipe);
} catch (IOException | InterruptedException e) {
throw new UpdateException("Unable to update recipe to server for " + selectedRecipe);
}
});
this.stepListCtrl.setUpdateCallback(newList -> {
Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem();
if (selectedRecipe == null) { // edge case error for NPE.
throw new NullPointerException("Null recipe whereas ingredients are edited");
}
selectedRecipe.setPreparationSteps(newList);
try { // propagate changes to server
server.updateRecipe(selectedRecipe);
} catch (IOException | InterruptedException e) {
throw new UpdateException("Unable to update recipe to server for " + selectedRecipe);
}
});
}
@Override
public void initializeComponents() {
config = configService.getConfig();
initStepsIngredientsList();
initRecipeList();
// Double-click to go to detail screen
@ -223,25 +164,18 @@ public class FoodpalApplicationCtrl implements LocaleAware {
openSelectedRecipe();
}
});
this.initializeSearchBar();
refresh();
updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem());
}
private void showName(String name) {
final int NAME_FONT_SIZE = 20;
editableTitleArea.getChildren().clear();
Label nameLabel = new Label(name);
nameLabel.setFont(new Font("System Bold", NAME_FONT_SIZE));
editableTitleArea.getChildren().add(nameLabel);
this.recipeDetailController.setCurrentlyViewedRecipe(recipeList.getSelectionModel().getSelectedItem());
}
@Override
public void updateText() {
addRecipeButton.setText(getLocaleString("menu.button.add.recipe"));
removeRecipeButton.setText(getLocaleString("menu.button.remove.recipe"));
cloneRecipeButton.setText(getLocaleString("menu.button.clone"));
editRecipeTitleButton.setText(getLocaleString("menu.button.edit"));
removeRecipeButton2.setText(getLocaleString("menu.button.remove.recipe"));
printRecipeButton.setText(getLocaleString("menu.button.print"));
recipesLabel.setText(getLocaleString("menu.label.recipes"));
}
@ -250,21 +184,12 @@ public class FoodpalApplicationCtrl implements LocaleAware {
return localeManager;
}
private void showRecipeDetails(Recipe recipe) {
if (recipe == null) {
return;
}
showName(recipe.getName());
ingredientListCtrl.refetchFromRecipe(recipe);
stepListCtrl.refetchFromRecipe(recipe);
}
// Button handlers
@FXML
public void refresh() {
List<Recipe> recipes;
try {
recipes = server.getRecipesFiltered(searchBarCtrl.getFilter());
recipes = server.getRecipesFiltered(searchBarController.getFilter());
} catch (IOException | InterruptedException e) {
recipes = Collections.emptyList();
System.err.println("Failed to load recipes: " + e.getMessage());
@ -303,11 +228,24 @@ public class FoodpalApplicationCtrl implements LocaleAware {
printError("Error occurred when adding recipe!");
}
// Calls edit after the recipe has been created, seamless name editing
editRecipeTitle();
// editRecipeTitle();
}
@FXML
private void removeSelectedRecipe() throws IOException, InterruptedException {
private void openSelectedRecipe() {
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
}
this.recipeDetailController.setVisible(true);
}
/**
* Removes the selected recipe from the server and refreshes the recipe list.
*/
@FXML
public void removeSelectedRecipe() throws IOException, InterruptedException {
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
@ -321,52 +259,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
refresh();
}
@FXML
private void openSelectedRecipe() {
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
}
detailsScreen.visibleProperty().set(true);
}
/**
* Revised edit recipe control flow, deprecates the use of a separate AddNameCtrl.
* This is automagically called when a new recipe is created, making for a more seamless UX.
*/
@FXML
private void editRecipeTitle() {
editableTitleArea.getChildren().clear();
TextField edit = new TextField();
edit.setOnKeyPressed(event -> {
if (event.getCode() != KeyCode.ENTER) {
return;
}
String newName = edit.getText();
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
if (selected == null) {
// edge case, prob won't happen but best to handle it later
throw new NullPointerException("No recipe selected while name was changed!");
}
selected.setName(newName);
try {
server.updateRecipe(selected);
refresh();
//recipeList.getSelectionModel().select(selected);
} catch (IOException | InterruptedException e) {
// throw a nice blanket UpdateException
throw new UpdateException("Error occurred when updating recipe name!");
}
showName(edit.getText());
});
editableTitleArea.getChildren().add(edit);
edit.requestFocus();
}
@FXML
private void makePrintable() {
System.out.println("Recipe printed");
}
/**
* Clones a recipe, when clicking on the button "clone".
*/
@ -381,48 +273,19 @@ 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();
//instant ui update
applyRecipeFilterAndKeepSelection();
recipeList.refresh();
updateFavouriteButton(selected);
}
@FXML
private void toggleFavouritesView() {
applyRecipeFilterAndKeepSelection();
}
private void applyRecipeFilterAndKeepSelection() {
/**
* Applies the recipe filter (favourites only or all) while keeping the current selection if possible.
* If the previously selected recipe is no longer visible after applying the filter,
* the first recipe in the filtered list will be selected instead.
* If there are no recipes after filtering, no selection will be made.
*
*/
public void applyRecipeFilterAndKeepSelection() {
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
Long selectedId = selected == null ? null : selected.getId();
@ -452,8 +315,8 @@ public class FoodpalApplicationCtrl implements LocaleAware {
recipeList.getSelectionModel().selectFirst();
}
updateFavouriteButton(recipeList.getSelectionModel().getSelectedItem());
detailsScreen.visibleProperty().set(!recipeList.getItems().isEmpty());
this.recipeDetailController.refreshFavouriteButton();
this.recipeDetailController.setVisible(!recipeList.getItems().isEmpty());
}
}

View file

@ -0,0 +1,300 @@
package client.scenes.recipe;
import client.exception.UpdateException;
import client.scenes.FoodpalApplicationCtrl;
import client.utils.Config;
import client.utils.ConfigService;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
import com.google.inject.Inject;
import commons.Recipe;
import java.io.IOException;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
/**
* Controller for the recipe detail view.
* Manages displaying and editing recipe details such as name, ingredients, and
* steps.
*/
public class RecipeDetailCtrl implements LocaleAware {
private final LocaleManager localeManager;
private final ServerUtils server;
private final FoodpalApplicationCtrl appCtrl;
private final ConfigService configService;
@FXML
private IngredientListCtrl ingredientListController;
@FXML
private RecipeStepListCtrl stepListController;
private Recipe recipe;
@Inject
public RecipeDetailCtrl(LocaleManager localeManager, ServerUtils server,
FoodpalApplicationCtrl appCtrl,
ConfigService configService) {
this.localeManager = localeManager;
this.server = server;
this.appCtrl = appCtrl;
this.configService = configService;
}
@FXML
private VBox detailsScreen;
@FXML
private HBox editableTitleArea;
@FXML
private Button editRecipeTitleButton;
@FXML
private Button removeRecipeButton;
@FXML
private Button printRecipeButton;
@FXML
private Button favouriteButton;
private ListView<Recipe> getParentRecipeList() {
return this.appCtrl.recipeList;
}
/**
* Convenience method for a frequently needed operation to retrieve the
* currently selected recipe.
*
* @return The currently selected recipe in the parent recipe list.
*/
private Optional<Recipe> getSelectedRecipe() {
return Optional.ofNullable(
this.getParentRecipeList().getSelectionModel().getSelectedItem());
}
/**
* Convenience method for a frequently needed operation to retrieve the global
* application configuration.
*
* @return The application configuration.
*/
private Config getConfig() {
return this.configService.getConfig();
}
/**
* Refreshes the recipe list from the server.
*
* @throws IOException Upon invalid recipe response.
* @throws InterruptedException Upon request interruption.
*
* @see FoodpalApplicationCtrl#refresh()
*/
private void refresh() throws IOException, InterruptedException {
this.appCtrl.refresh();
}
/**
* Toggles the visibility of the recipe details screen.
*
* @param visible Whether the details screen should be visible.
*/
public void setVisible(boolean visible) {
this.detailsScreen.setVisible(visible);
}
/**
* Sets the currently viewed recipe in the detail view.
*
* @param recipe The recipe to view.
*/
public void setCurrentlyViewedRecipe(Recipe recipe) {
if (recipe == null) {
return;
}
this.recipe = recipe;
this.showName(recipe.getName());
this.ingredientListController.refetchFromRecipe(recipe);
this.stepListController.refetchFromRecipe(recipe);
this.refreshFavouriteButton();
}
/**
* Create a callback that takes in the selected recipe and a helper type
* and updates the recipe based on that.
* Also, posts the update to the server.
*
* @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
*/
private <T> Consumer<T> createUpdateRecipeCallback(BiConsumer<Recipe, T> recipeConsumer) {
return recipes -> {
Recipe selectedRecipe = this.getSelectedRecipe().orElseThrow(
() -> new NullPointerException(
"Null recipe whereas ingredients are edited"));
recipeConsumer.accept(selectedRecipe, recipes);
try { // propagate changes to server
server.updateRecipe(selectedRecipe);
} catch (IOException | InterruptedException e) {
throw new UpdateException("Unable to update recipe to server for " +
selectedRecipe);
}
};
}
/**
* Initializes the ingredient and step list controllers with update callbacks.
*/
private void initStepsIngredientsList() {
// code does NOT get nicer than this :pray:
// Initialize callback for ingredient list updates
this.ingredientListController.setUpdateCallback(
this.createUpdateRecipeCallback(Recipe::setIngredients));
// Ditto, for step list updates
this.stepListController.setUpdateCallback(
this.createUpdateRecipeCallback(Recipe::setPreparationSteps));
}
/**
* Revised edit recipe control flow, deprecates the use of a separate
* AddNameCtrl. This is automagically called when a new recipe is created,
* making for a more seamless UX.
*/
@FXML
private void editRecipeTitle() {
this.editableTitleArea.getChildren().clear();
TextField edit = new TextField();
edit.setOnKeyPressed(event -> {
if (event.getCode() != KeyCode.ENTER) {
return;
}
String newName = edit.getText();
this.recipe.setName(newName);
try {
server.updateRecipe(this.recipe);
this.refresh();
} catch (IOException | InterruptedException e) {
// throw a nice blanket UpdateException
throw new UpdateException("Error occurred when updating recipe name!");
}
this.showName(edit.getText());
});
editableTitleArea.getChildren().add(edit);
edit.requestFocus();
}
/**
* Remove the selected recipe from the server and refresh the recipe list.
* Internally calls {@link FoodpalApplicationCtrl#removeSelectedRecipe()}.
*/
@FXML
private void removeSelectedRecipe() throws IOException, InterruptedException {
this.appCtrl.removeSelectedRecipe();
}
@FXML
/**
* Print the currently viewed recipe.
*/
private void printRecipe() {
// TODO: actually make it print?
System.out.println("Recipe printed");
}
/**
* Refreshes the favourite button state based on whether the currently viewed
* recipe is marked as a favourite in the application configuration.
*/
public void refreshFavouriteButton() {
if (recipe == null) {
favouriteButton.setDisable(true);
favouriteButton.setText("");
return;
}
favouriteButton.setDisable(false);
favouriteButton.setText(this.getConfig().isFavourite(recipe.getId()) ? ""
: "");
}
/**
* Toggles the favourite status of the currently viewed recipe in the
* application configuration and writes the changes to disk.
*/
@FXML
private void toggleFavourite() {
long id = this.recipe.getId();
Config config = this.getConfig();
if (config.isFavourite(id)) {
config.removeFavourite(id);
} else {
config.addFavourite(id);
}
configService.save();
// instant ui update
appCtrl.applyRecipeFilterAndKeepSelection();
this.getParentRecipeList().refresh();
refreshFavouriteButton();
}
/**
* Updates the title area to show the given name.
*
* @param name The name to show.
*/
private void showName(String name) {
final int NAME_FONT_SIZE = 20;
editableTitleArea.getChildren().clear();
Label nameLabel = new Label(name);
nameLabel.setFont(new Font("System Bold", NAME_FONT_SIZE));
editableTitleArea.getChildren().add(nameLabel);
}
@Override
public void updateText() {
editRecipeTitleButton.setText(getLocaleString("menu.button.edit"));
removeRecipeButton.setText(getLocaleString("menu.button.remove.recipe"));
printRecipeButton.setText(getLocaleString("menu.button.print"));
}
@Override
public LocaleManager getLocaleManager() {
return this.localeManager;
}
@Override
public void initializeComponents() {
initStepsIngredientsList();
}
}

View file

@ -46,7 +46,7 @@
<Font name="System Bold" size="15.0" />
</font></Label>
<fx:include source="SearchBar.fxml" />
<fx:include source="SearchBar.fxml" fx:id="searchBar" />
<ListView fx:id="recipeList" />
@ -61,27 +61,7 @@
<!-- CENTER: RECIPE DETAILS -->
<center>
<VBox spacing="20" visible="false" fx:id="detailsScreen">
<padding>
<Insets bottom="10" left="10" right="10" top="10" />
</padding>
<!-- Recipe title row -->
<HBox spacing="10">
<HBox fx:id="editableTitleArea">
</HBox>
<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 -->
<fx:include source="recipe/IngredientList.fxml" />
<!-- Preparation -->
<fx:include source="recipe/RecipeStepList.fxml" />
</VBox>
<fx:include source="recipe/RecipeDetailView.fxml" fx:id="recipeDetail" />
</center>
</BorderPane>

View file

@ -0,0 +1,35 @@
<?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.geometry.Insets?>
<VBox
xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="client.scenes.recipe.RecipeDetailCtrl"
spacing="20" visible="false" fx:id="detailsScreen">
<padding>
<Insets bottom="10" left="10" right="10" top="10" />
</padding>
<!-- Recipe title row -->
<HBox spacing="10">
<HBox fx:id="editableTitleArea">
</HBox>
<Button fx:id="editRecipeTitleButton" onAction="#editRecipeTitle" text="Edit" />
<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="☆" />
</HBox>
<!-- Ingredients -->
<fx:include source="IngredientList.fxml" fx:id="ingredientList" />
<!-- Preparation -->
<fx:include source="RecipeStepList.fxml" fx:id="stepList" />
</VBox>