diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index e9d6061..d989291 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -35,7 +35,7 @@ public class PerfinApp extends Application { PerfinApp::defineRoutes, PerfinApp::initAppDir, c -> initMainScreen(stage, c), - PerfinApp::loadProfile + PerfinApp::loadLastUsedProfile )); splashScreen.showAndWait(); if (splashScreen.isStartupSuccessful()) { @@ -43,7 +43,7 @@ public class PerfinApp extends Application { } } - private void initMainScreen(Stage stage, Consumer msgConsumer) throws Exception { + private void initMainScreen(Stage stage, Consumer msgConsumer) { msgConsumer.accept("Initializing main screen."); Platform.runLater(() -> { stage.hide(); @@ -57,7 +57,7 @@ public class PerfinApp extends Application { router.map(route, PerfinApp.class.getResource(resource)); } - private static void defineRoutes(Consumer msgConsumer) throws Exception { + private static void defineRoutes(Consumer msgConsumer) { msgConsumer.accept("Initializing application views."); Platform.runLater(() -> { mapResourceRoute("accounts", "/accounts-view.fxml"); @@ -80,7 +80,7 @@ public class PerfinApp extends Application { } } - private static void loadProfile(Consumer msgConsumer) throws Exception { + private static void loadLastUsedProfile(Consumer msgConsumer) throws Exception { msgConsumer.accept("Loading the most recent profile."); Profile.loadLast(); } diff --git a/src/main/java/com/andrewlalis/perfin/control/MainViewController.java b/src/main/java/com/andrewlalis/perfin/control/MainViewController.java index 3b4d295..13f8bae 100644 --- a/src/main/java/com/andrewlalis/perfin/control/MainViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/MainViewController.java @@ -2,6 +2,7 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView; import com.andrewlalis.perfin.view.BindingUtil; +import com.andrewlalis.perfin.view.ProfilesStage; import javafx.fxml.FXML; import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; @@ -10,13 +11,10 @@ import javafx.scene.layout.HBox; import static com.andrewlalis.perfin.PerfinApp.router; public class MainViewController { - @FXML - public BorderPane mainContainer; - @FXML - public HBox breadcrumbHBox; + @FXML public BorderPane mainContainer; + @FXML public HBox breadcrumbHBox; - @FXML - public void initialize() { + @FXML public void initialize() { AnchorPaneRouterView routerView = (AnchorPaneRouterView) router.getView(); mainContainer.setCenter(routerView.getAnchorPane()); @@ -36,25 +34,25 @@ public class MainViewController { router.navigate("accounts"); } - @FXML - public void goBack() { + @FXML public void goBack() { router.navigateBack(); } - @FXML - public void goForward() { + @FXML public void goForward() { router.navigateForward(); } - @FXML - public void goToAccounts() { + @FXML public void goToAccounts() { router.getHistory().clear(); router.navigate("accounts"); } - @FXML - public void goToTransactions() { + @FXML public void goToTransactions() { router.getHistory().clear(); router.navigate("transactions"); } + + @FXML public void viewProfiles() { + ProfilesStage.open(mainContainer.getScene().getWindow()); + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/Popups.java b/src/main/java/com/andrewlalis/perfin/control/Popups.java index beae679..7ffa3da 100644 --- a/src/main/java/com/andrewlalis/perfin/control/Popups.java +++ b/src/main/java/com/andrewlalis/perfin/control/Popups.java @@ -11,4 +11,17 @@ public class Popups { var result = alert.showAndWait(); return result.isPresent() && result.get() == ButtonType.OK; } + + public static void message(String text) { + Alert alert = new Alert(Alert.AlertType.NONE, text); + alert.initModality(Modality.APPLICATION_MODAL); + alert.getButtonTypes().setAll(ButtonType.OK); + alert.showAndWait(); + } + + public static void error(String text) { + Alert alert = new Alert(Alert.AlertType.WARNING, text); + alert.initModality(Modality.APPLICATION_MODAL); + alert.showAndWait(); + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java new file mode 100644 index 0000000..3fe2620 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -0,0 +1,147 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.perfin.data.FileUtil; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.view.ProfilesStage; +import javafx.beans.binding.BooleanExpression; +import javafx.beans.property.BooleanProperty; +import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class ProfilesViewController { + @FXML public VBox profilesVBox; + @FXML public TextField newProfileNameField; + @FXML public Text newProfileNameErrorLabel; + @FXML public Button addProfileButton; + + @FXML public void initialize() { + BooleanExpression newProfileNameValid = BooleanProperty.booleanExpression(newProfileNameField.textProperty() + .map(text -> ( + text != null && + !text.isBlank() && + Profile.validateName(text) && + !Profile.getAvailableProfiles().contains(text) + ))); + newProfileNameErrorLabel.managedProperty().bind(newProfileNameErrorLabel.visibleProperty()); + newProfileNameErrorLabel.visibleProperty().bind(newProfileNameValid.not().and(newProfileNameField.textProperty().isNotEmpty())); + newProfileNameErrorLabel.wrappingWidthProperty().bind(newProfileNameField.widthProperty()); + addProfileButton.disableProperty().bind(newProfileNameValid.not()); + + refreshAvailableProfiles(); + } + + @FXML public void addProfile() { + String name = newProfileNameField.getText(); + boolean valid = Profile.validateName(name); + if (valid && !Profile.getAvailableProfiles().contains(name)) { + boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?"); + if (confirm) { + if (openProfile(name, false)) { + Popups.message("Created new profile \"" + name + "\" and loaded it."); + } + newProfileNameField.clear(); + } + } + } + + private void refreshAvailableProfiles() { + List profileNames = Profile.getAvailableProfiles(); + String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName(); + List nodes = new ArrayList<>(profileNames.size()); + for (String profileName : profileNames) { + boolean isCurrent = profileName.equals(currentProfile); + AnchorPane profilePane = new AnchorPane(); + profilePane.setStyle(""" + -fx-border-color: lightgray; + -fx-border-radius: 5px; + -fx-padding: 5px; + """); + + Text nameTextElement = new Text(profileName); + nameTextElement.setStyle("-fx-font-size: large;"); + TextFlow nameLabel = new TextFlow(nameTextElement); + if (isCurrent) { + nameTextElement.setStyle("-fx-font-size: large; -fx-font-weight: bold;"); + Text currentProfileIndicator = new Text(" Currently Selected Profile"); + currentProfileIndicator.setStyle(""" + -fx-font-size: small; + -fx-fill: grey; + """); + nameLabel.getChildren().add(currentProfileIndicator); + } + AnchorPane.setLeftAnchor(nameLabel, 0.0); + AnchorPane.setTopAnchor(nameLabel, 0.0); + AnchorPane.setBottomAnchor(nameLabel, 0.0); + + HBox buttonBox = new HBox(); + AnchorPane.setRightAnchor(buttonBox, 0.0); + AnchorPane.setTopAnchor(buttonBox, 0.0); + AnchorPane.setBottomAnchor(buttonBox, 0.0); + buttonBox.getStyleClass().addAll("std-spacing"); + Button openButton = new Button("Open"); + openButton.setOnAction(event -> openProfile(profileName, false)); + openButton.setDisable(isCurrent); + buttonBox.getChildren().add(openButton); + Button deleteButton = new Button("Delete"); + deleteButton.setOnAction(event -> deleteProfile(profileName)); + buttonBox.getChildren().add(deleteButton); + + profilePane.getChildren().setAll(nameLabel, buttonBox); + nodes.add(profilePane); + } + profilesVBox.getChildren().setAll(nodes); + } + + private boolean openProfile(String name, boolean showPopup) { + System.out.println("Opening profile: " + name); + try { + Profile.load(name); + ProfilesStage.closeView(); + router.getHistory().clear(); + router.navigate("accounts"); + if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded."); + return true; + } catch (IOException e) { + e.printStackTrace(System.err); + Popups.error("Failed to load profile: " + e.getMessage()); + return false; + } + } + + private void deleteProfile(String name) { + boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered."); + if (confirmA) { + boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back."); + if (confirmB) { + try { + FileUtil.deleteDirRecursive(Profile.getDir(name)); + // Reset the app's "last profile" to the default if it was the deleted profile. + if (Profile.getLastProfile().equals(name)) { + Profile.saveLastProfile("default"); + } + // If the current profile was deleted, switch to the default. + if (Profile.getCurrent() != null && Profile.getCurrent().getName().equals(name)) { + openProfile("default", true); + } + refreshAvailableProfiles(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/FileUtil.java b/src/main/java/com/andrewlalis/perfin/data/FileUtil.java index 86b24d4..c5b36ab 100644 --- a/src/main/java/com/andrewlalis/perfin/data/FileUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/FileUtil.java @@ -1,5 +1,11 @@ package com.andrewlalis.perfin.data; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.HashMap; import java.util.Map; @@ -22,4 +28,20 @@ public class FileUtil { MIMETYPES.put(".bmp", "image/bmp"); MIMETYPES.put(".tiff", "image/tiff"); } + + public static void deleteDirRecursive(Path startDir) throws IOException { + Files.walkFileTree(startDir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 743d4a4..1ead66f 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -9,10 +9,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Properties; +import java.util.*; import java.util.function.Consumer; /** @@ -86,6 +83,16 @@ public class Profile { } } + public static List getAvailableProfiles() { + try (var files = Files.list(PerfinApp.APP_DIR)) { + return files.filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .sorted().toList(); + } catch (IOException e) { + return Collections.emptyList(); + } + } + public static String getLastProfile() { Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt"); if (Files.exists(lastProfileFile)) { @@ -127,7 +134,6 @@ public class Profile { for (var c : profileLoadListeners) { c.accept(current); } - profileLoadListeners.clear(); } private static void initProfileDir(String name) throws IOException { @@ -181,6 +187,13 @@ public class Profile { } public static boolean validateName(String name) { - return name.matches("\\w+"); + return name != null && + name.matches("\\w+") && + name.toLowerCase().equals(name); + } + + @Override + public String toString() { + return name; } } diff --git a/src/main/java/com/andrewlalis/perfin/view/ProfilesStage.java b/src/main/java/com/andrewlalis/perfin/view/ProfilesStage.java new file mode 100644 index 0000000..2a0d1e6 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/ProfilesStage.java @@ -0,0 +1,40 @@ +package com.andrewlalis.perfin.view; + +import com.andrewlalis.perfin.SceneUtil; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * A stage that shows a popup for interacting with Perfin's collection of + * profiles. + */ +public class ProfilesStage extends Stage { + private static ProfilesStage instance; + + public ProfilesStage() { + setTitle("Profiles"); + setAlwaysOnTop(false); + initModality(Modality.APPLICATION_MODAL); + setScene(SceneUtil.load("/profiles-view.fxml")); + } + + public static void open(Window owner) { + if (instance == null) { + instance = new ProfilesStage(); + instance.initOwner(owner); + instance.show(); + instance.setOnCloseRequest(event -> instance = null); + } else { + instance.requestFocus(); + instance.toFront(); + } + } + + public static void closeView() { + if (instance != null) { + instance.close(); + instance = null; + } + } +} diff --git a/src/main/resources/main-view.fxml b/src/main/resources/main-view.fxml index 5fd34c2..02ee160 100644 --- a/src/main/resources/main-view.fxml +++ b/src/main/resources/main-view.fxml @@ -16,6 +16,7 @@