csep-2025/client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java
2026-01-18 16:50:48 +01:00

370 lines
11 KiB
Java

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.PrintExportService;
import client.utils.server.ServerUtils;
import client.utils.WebSocketDataService;
import com.google.inject.Inject;
import commons.Recipe;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
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.control.TextInputDialog;
import javafx.scene.control.ComboBox;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
import javafx.stage.DirectoryChooser;
/**
* 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;
private final WebSocketDataService<Long, Recipe> webSocketDataService;
@FXML
private IngredientListCtrl ingredientListController;
@FXML
private RecipeStepListCtrl stepListController;
private Recipe recipe;
@Inject
public RecipeDetailCtrl(
LocaleManager localeManager,
ServerUtils server,
FoodpalApplicationCtrl appCtrl,
ConfigService configService,
WebSocketDataService<Long, Recipe> webSocketDataService) {
this.localeManager = localeManager;
this.server = server;
this.appCtrl = appCtrl;
this.configService = configService;
this.webSocketDataService = webSocketDataService;
}
@FXML
private VBox detailsScreen;
@FXML
private HBox editableTitleArea;
@FXML
private Button editRecipeTitleButton;
@FXML
private Button removeRecipeButton;
@FXML
private Button printRecipeButton;
@FXML
private Button favouriteButton;
@FXML
private ComboBox<String> langSelector;
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.langSelector.setValue(recipe.getLocale());
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);
webSocketDataService.add(selectedRecipe.getId(), recipe -> {
int idx = getParentRecipeList().getItems().indexOf(selectedRecipe);
getParentRecipeList().getItems().set(idx, recipe);
});
} 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.
* Public for reference by ApplicationCtrl.
*/
@FXML
public 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();
}
/**
* 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()) ? ""
: "");
}
/**
* Gives the User a download file prompt and lets the
* user change the name of the downloaded file and
* uses printExportService to create a downloadable file.
*/
@FXML
private void printRecipe() {
if (recipe == null) {
return; // Do nothing if no recipe selected
}
// Open directory chooser
DirectoryChooser directoryChooser = new DirectoryChooser();
directoryChooser.setTitle("Select Folder to Save Recipe");
File selectedDirectory = directoryChooser.showDialog(
printRecipeButton.getScene().getWindow());
if (selectedDirectory == null) {
return; // User cancelled
}
// Ask for filename
TextInputDialog dialog = new TextInputDialog(recipe.getName() + ".txt");
dialog.setTitle("Save Recipe");
dialog.setHeaderText("Enter filename for the recipe");
dialog.setContentText("Filename:");
Optional<String> result = dialog.showAndWait();
if (result.isPresent()) {
String filename = result.get();
if (!filename.endsWith(".txt")) {
filename = filename + ".txt";
}
// Use PrintExportService methods
String recipeText = PrintExportService.buildRecipeText(recipe);
Path dirPath = selectedDirectory.toPath();
PrintExportService.exportToFile(recipeText, dirPath, filename);
}
}
/**
* 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
refreshFavouriteButton();
appCtrl.refresh();
}
/**
* 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);
}
/**
* Switch the recipe's language.
*/
@FXML
void changeLanguage() {
recipe.setLocale(this.langSelector.getValue());
try {
server.updateRecipe(this.recipe);
} catch (IOException | InterruptedException e) {
throw new UpdateException("Error occurred when updating recipe locale!");
}
}
@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();
langSelector.getItems().addAll("en", "nl", "pl");
}
}