AppController.java

// Copyright (c) 2023 Tobias Briones. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
// This file is part of https://github.com/tobiasbriones/blog

package engineer.mathsoftware.blog.slides.ui;

import engineer.mathsoftware.blog.slides.*;
import engineer.mathsoftware.blog.slides.data.*;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.DragEvent;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.stage.FileChooser;

import java.awt.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class AppController implements
    ImageItemCell.Listener,
    SlideDrawingView.ChangeListener {

    private final DataRepository repository;
    private final ObservableList<ImageItem> images;
    private final Map<ImageItem, SlideDrawingView.SlideState> changes;
    private SlideDrawingView slideDrawingView;
    @FXML
    private Parent view;
    @FXML
    private Button addButton;
    @FXML
    private Button newButton;
    @FXML
    private Label statusLabel;
    @FXML
    private Label secondaryStatusLabel;
    @FXML
    private ListView<ImageItem> imageList;
    @FXML
    private Pagination pagination;
    @FXML
    private ScrollPane scrollPane;
    @FXML
    private HBox slideBox;
    @FXML
    private VBox screenshotBox;
    @FXML
    private VBox codeSnippetBox;
    @FXML
    private CheckMenuItem autoSaveCheckMenuItem;
    @FXML
    private ComboBox<SlideItem> slideComboBox;
    @FXML
    private ComboBox<Language> languageComboBox;
    @FXML
    private ComboBox<SlideSize.Predefined> sizeComboBox;
    @FXML
    private TextField saveField;
    @FXML
    private ColorPicker backgroundPicker;
    @FXML
    private TextArea codeTextArea;
    @FXML
    private CheckBox captionCheckBox;
    @FXML
    private VBox captionBox;
    @FXML
    private TextArea captionTitleArea;
    @FXML
    private TextArea captionSubTitleArea;
    @FXML
    private ComboBox<ShapeItem> shapeComboBox;
    @FXML
    private ComboBox<Palette> shapePaletteComboBox;
    @FXML
    private Button shapeBackButton;

    public AppController() {
        this.repository = new LocalDataRepository(Env.DATA_ROOT);
        this.images = FXCollections.observableArrayList();
        this.changes = new HashMap<>();
    }

    @FXML
    public void initialize() {
        setStatus("Slides App");
        imageList.setCellFactory(param -> new ImageItemCell(this));
        imageList.setItems(images);

        initAddButton();
        initNewButton();
        loadImageList();

        initMasterView();
        initDetail();
        initSlideDrawingView();
        initMenu();
    }

    @FXML
    private void onDragOver(DragEvent dragEvent) {
        var dragboard = dragEvent.getDragboard();

        if (dragboard.hasFiles()) {
            if (Data.areValidImageFiles(dragboard.getFiles())) {
                setStatus("Dragging files...");
                dragEvent.acceptTransferModes(TransferMode.COPY);
            }
            else {
                setStatus("Drag canceled (invalid files)");
            }
            dragEvent.consume();
        }
    }

    @FXML
    private void onDragExited(DragEvent dragEvent) {
        if (!dragEvent.isDropCompleted()) {
            setStatus("Drag canceled");
        }
        dragEvent.consume();
    }

    @FXML
    private void onDragDropped(DragEvent dragEvent) {
        var board = dragEvent.getDragboard();

        if (board.hasFiles()) {
            createOrUpdateImages(board.getFiles());
            dragEvent.setDropCompleted(true);
            dragEvent.consume();
        }
        else {
            setStatus("Drag canceled (empty)");
        }
    }

    @FXML
    private void onAddButtonAction() {
        openFileViaFileChooser();
    }

    @FXML
    private void onNewButtonAction() {
        var dialog = new TextInputDialog();

        dialog.setTitle("Slide Name");
        dialog.setHeaderText(null);
        dialog.setContentText("Enter the slide name:");

        dialog
            .showAndWait()
            .filter(x -> !x.isBlank())
            .ifPresent(this::createNewSlide);
    }

    @FXML
    private void onClearButtonAction() {
        showDeleteAllAlert();
    }

    @FXML
    private void onAddMenuItemAction() {
        openFileViaFileChooser();
    }

    @FXML
    private void onSaveCurrentSlideItemAction() {
        slideDrawingView.saveCurrentSlide();
    }

    @FXML
    private void onOpenWDMenuItemAction() {
        openInExplorer(Env.DATA_ROOT);
    }

    @FXML
    private void onOpenPDMenuItemAction() {
        openInExplorer(Env.SAVE_DIR);
    }

    @FXML
    private void onClearMenuItemAction() {
        showDeleteAllAlert();
    }

    @FXML
    private void onQuitMenuItemAction() {
        Platform.exit();
    }

    @FXML
    private void onAboutMenuItemAction() {
        var alert = new Alert(
            Alert.AlertType.INFORMATION,
            "Slides App | blog | mathsoftware.engineer",
            ButtonType.CLOSE
        );

        alert.showAndWait();
    }

    @Override
    public void onDelete(ImageItem item) {
        try {
            repository.deleteImage(item.filename());
            imageList.getItems().remove(item);
            setStatus("Item deleted");
        }
        catch (IOException e) {
            handleError(e);
        }
    }

    @Override
    public void onArrange(int draggedIdx, int destIdx) {
        var dragged = images.get(draggedIdx);
        var dest = images.get(destIdx);

        images.set(draggedIdx, dest);
        images.set(destIdx, dragged);
        imageList.getSelectionModel().clearAndSelect(destIdx);
        setStatus("Item arranged");
    }

    @Override
    public void onSlideChange(SlideDrawingView.SlideState state) {
        changes.put(state.imageItem(), state);
    }

    @Override
    public void setState(ImageItem item) {
        if (!changes.containsKey(item)) {
            slideComboBox.setValue(SlideItem.Screenshot);
            languageComboBox.setValue(Language.Java);
            backgroundPicker.setValue(Color.WHITE);
            codeTextArea.setText("");
            captionCheckBox.setSelected(false);
            captionTitleArea.setText("");
            captionSubTitleArea.setText("");
            sizeComboBox.setValue(SlideSize.Predefined.HD);
            return;
        }
        var state = changes.get(item);

        slideComboBox.setValue(state.slideItem());
        languageComboBox.setValue(state.language());
        backgroundPicker.setValue(state.background());
        codeTextArea.setText(state.code());
        captionCheckBox.setSelected(state.captionEnable());
        state.caption().ifPresent(caption -> {
            captionTitleArea.setText(caption.title());
            captionSubTitleArea.setText(caption.subtitle());
        });
        sizeComboBox.setValue(SlideSize.Predefined.from(state.size()));
    }

    private void loadImageList() {
        try {
            var loadedImages = repository.readAllImages();

            images.clear();
            images.addAll(loadedImages);
        }
        catch (IOException e) {
            handleError(e);
        }
    }

    private void createNewSlide(String name) {
        var newImage = new Image(Objects.requireNonNull(
            getClass().getClassLoader().getResourceAsStream("app-512x512.png")
        ));
        var newItem = new ImageItem(name + ".png", newImage);

        try {
            repository.createOrUpdateImage(newItem);
            images.remove(newItem);
            images.add(newItem);
            setStatus("New slide created");
        }
        catch (IOException e) {
            handleError(e);
        }
    }

    private void openFileViaFileChooser() {
        var chooser = new FileChooser();

        chooser.setTitle("Open Files");
        chooser.getExtensionFilters().addAll(
            new FileChooser.ExtensionFilter(
                "Image Files (*.png, *.jpg)",
                "*.png",
                "*.jpg"
            )
        );
        var files = chooser.showOpenMultipleDialog(view.getScene().getWindow());

        if (files != null) {
            createOrUpdateImages(files);
        }
    }

    private void createOrUpdateImages(Iterable<? extends File> files) {
        for (var file : files) {
            var path = file.toPath();

            try {
                repository.createOrUpdateImage(path);

                var imageName = path.getFileName().toString();
                var newImage = repository.readImage(imageName);
                var newImageItem = new ImageItem(imageName, newImage);
                var listItems = imageList.getItems();

                listItems.remove(newImageItem);
                listItems.add(new ImageItem(imageName, newImage));
                setStatus("Files updated");
            }
            catch (IOException e) {
                handleError(e);
            }
        }
    }

    private void initAddButton() {
        var icAdd = new Image(
            Objects.requireNonNull(
                getClass().getResourceAsStream("/ic_add.png")
            )
        );
        var addImageView = new ImageView(icAdd);

        addImageView.setFitWidth(18.0);
        addImageView.setFitHeight(18.0);
        addButton.setGraphic(addImageView);
    }

    private void initNewButton() {
        var icNew = new Image(
            Objects.requireNonNull(
                getClass().getResourceAsStream("/ic_new.png")
            )
        );
        var newImageView = new ImageView(icNew);

        newImageView.setFitWidth(18.0);
        newImageView.setFitHeight(18.0);
        newButton.setGraphic(newImageView);
    }

    private void initMenu() {
        slideDrawingView
            .autoSaveProperty()
            .bind(autoSaveCheckMenuItem
                .selectedProperty()
            );
    }

    private void initMasterView() {
        updatePaginationCount();

        images.addListener((InvalidationListener) l -> updatePaginationCount());

        imageList
            .getSelectionModel()
            .selectedIndexProperty()
            .map(Integer.class::cast)
            .addListener((observable, oldValue, newValue) -> pagination
                .setCurrentPageIndex(newValue)
            );

        imageList
            .getSelectionModel()
            .select(0);

        pagination
            .currentPageIndexProperty()
            .map(Integer.class::cast)
            .addListener((observable, oldValue, newValue) -> imageList
                .getSelectionModel()
                .select(newValue)
            );
    }

    private void initDetail() {
        var slideProperty = slideComboBox.valueProperty();

        slideComboBox.getItems().addAll(SlideItem.values());
        slideComboBox.setConverter(new Enums.EnglishConverter<>(SlideItem.class));
        slideComboBox.setValue(SlideItem.Screenshot);

        languageComboBox.getItems().addAll(Language.values());
        languageComboBox.setValue(Language.Java);
        languageComboBox
            .visibleProperty()
            .bind(slideProperty
                .isEqualTo(SlideItem.CodeSnippet)
                .or(slideProperty.isEqualTo(SlideItem.CodeShot))
            );
        languageComboBox
            .managedProperty()
            .bind(languageComboBox
                .visibleProperty()
            );

        sizeComboBox
            .getItems()
            .addAll(SlideSize
                .Predefined.values()
            );
        sizeComboBox
            .getSelectionModel()
            .select(0);

        saveField
            .setText(Path
                .of(Env.DATA_ROOT, "out")
                .toAbsolutePath()
                .toString()
            );

        screenshotBox
            .visibleProperty()
            .bind(slideProperty
                .isEqualTo(SlideItem.Screenshot)
            );
        screenshotBox
            .managedProperty()
            .bind(screenshotBox
                .visibleProperty()
            );

        codeSnippetBox
            .visibleProperty()
            .bind(slideProperty
                .isEqualTo(SlideItem.CodeSnippet)
            );
        codeSnippetBox
            .managedProperty()
            .bind(codeSnippetBox
                .visibleProperty()
            );

        captionBox
            .visibleProperty()
            .bind(captionCheckBox
                .selectedProperty()
            );
        captionBox
            .managedProperty()
            .bind(captionBox
                .visibleProperty()
            );
    }

    private void initSlideDrawingView() {
        slideDrawingView = new SlideDrawingView(slideBox);

        slideDrawingView
            .slideProperty()
            .bind(slideComboBox
                .valueProperty()
            );
        slideDrawingView
            .imageProperty()
            .bind(imageList
                .getSelectionModel()
                .selectedItemProperty()
            );
        slideDrawingView
            .languageProperty()
            .bind(languageComboBox
                .valueProperty()
            );
        slideDrawingView
            .backgroundProperty()
            .bind(backgroundPicker
                .valueProperty()
            );
        slideDrawingView
            .codeProperty()
            .bind(codeTextArea
                .textProperty()
            );
        slideDrawingView
            .sizeProperty()
            .bind(sizeComboBox
                .valueProperty()
                .map(SlideSize.Predefined::value)
            );
        slideDrawingView
            .captionEnableProperty()
            .bind(captionCheckBox
                .selectedProperty()
            );
        slideDrawingView
            .captionTitleProperty()
            .bind(captionTitleArea
                .textProperty()
            );
        slideDrawingView
            .captionSubTitleProperty()
            .bind(captionSubTitleArea
                .textProperty()
            );

        slideDrawingView
            .setViews(
                scrollPane,
                shapeComboBox,
                shapePaletteComboBox,
                shapeBackButton
            );
        slideDrawingView.setOnChangeListener(this);
        slideDrawingView.setStatus(this::setSecondaryStatus);
        slideDrawingView.init();
    }

    private void showDeleteAllAlert() {
        var alert = new Alert(
            Alert.AlertType.CONFIRMATION,
            "Delete all the data?",
            ButtonType.YES,
            ButtonType.NO
        );
        alert.showAndWait();

        if (alert.getResult() == ButtonType.YES) {
            deleteAllImages();
        }
    }

    private void deleteAllImages() {
        try {
            repository.deleteAllImages();
            imageList.getItems().clear();
            setStatus("All items deleted");
        }
        catch (IOException e) {
            handleError(e);
        }
    }

    private void updatePaginationCount() {
        var size = images.size();

        if (size > 0) {
            pagination.setVisible(true);
            pagination.setPageCount(images.size());
        }
        else {
            pagination.setVisible(false);
            pagination.setPageCount(1);
        }
    }

    private void handleError(IOException e) {
        setStatus(e.getMessage());
        e.printStackTrace();
    }

    private void setStatus(String msg) {
        statusLabel.setText(msg);
    }

    private void setSecondaryStatus(String msg) {
        secondaryStatusLabel.setText(msg);
    }

    private void openInExplorer(String dir) {
        if (Desktop.isDesktopSupported()) {
            try {
                Desktop.getDesktop().open(new File(dir));
            }
            catch (IOException | UnsupportedOperationException e) {
                setStatus(e.getMessage());
            }
        }
        else {
            setStatus("Desktop is not supported");
        }
    }
}