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; import client.utils.LocaleManager; 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; 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.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; import javafx.scene.control.ListView; import javafx.scene.control.Spinner; import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.TextField; import javafx.scene.control.TextInputDialog; import javafx.scene.input.KeyCode; 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. * 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 webSocketDataService; private final ShoppingListService shoppingListService; public Spinner scaleSpinner; public Label inferredKcalLabel; public Spinner servingsSpinner; public Label inferredServeSizeLabel; public Label scaleLabel; public Label servingLabel; public Button addToListButton; @FXML private IngredientListCtrl ingredientListController; @FXML private RecipeStepListCtrl stepListController; private Recipe recipe; private ScalableRecipeView recipeView; @Inject public RecipeDetailCtrl( LocaleManager localeManager, ServerUtils server, FoodpalApplicationCtrl appCtrl, ConfigService configService, ShoppingListService listService, WebSocketDataService webSocketDataService) { this.localeManager = localeManager; this.server = server; this.appCtrl = appCtrl; this.configService = configService; this.webSocketDataService = webSocketDataService; this.shoppingListService = listService; } @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 langSelector; @FXML private NutritionPieChartCtrl pieChartController; private ListView 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 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.langSelector.setValue(recipe.getLocale()); this.refreshFavouriteButton(); // If there is a scale // Prevents issues from first startup 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(getLocaleString("app.label.inferred-kcal") + " %.1f kcal/100g", 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(getLocaleString("app.label.inferred-size") + " %.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); } /** * 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 Consumer createUpdateRecipeCallback(BiConsumer 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 // NOTE Scale does not impact the server update (per backlog req.) as the scaled reference is held in // the scaled view which is only exposed to lists. 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 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")); addToListButton.setText(getLocaleString("app.word.shop")); scaleLabel.setText(getLocaleString("app.word.scale")); servingLabel.setText(getLocaleString("app.word.serving.n")); } @Override public LocaleManager getLocaleManager() { return this.localeManager; } @Override public void initializeComponents() { initStepsIngredientsList(); // creates a new scale spinner with an arbitrary max scale scaleSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(1, Double.MAX_VALUE, 1)); scaleSpinner.setEditable(true); scaleSpinner.valueProperty().addListener((observable, oldValue, newValue) -> { if (newValue == null) { return; } // triggers a UI update each time the spinner changes to a different value. setCurrentlyViewedRecipe(recipe); }); 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(); } }