Added profiles selector and logic for changing the selected profile.

This commit is contained in:
Andrew Lalis 2023-12-30 18:13:06 -05:00
parent 7d7f80676a
commit 755dc87aec
9 changed files with 315 additions and 24 deletions

View File

@ -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<String> msgConsumer) throws Exception {
private void initMainScreen(Stage stage, Consumer<String> 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<String> msgConsumer) throws Exception {
private static void defineRoutes(Consumer<String> 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<String> msgConsumer) throws Exception {
private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
msgConsumer.accept("Loading the most recent profile.");
Profile.loadLast();
}

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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<String> profileNames = Profile.getAvailableProfiles();
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName();
List<Node> 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);
}
}
}
}
}

View File

@ -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;
}
});
}
}

View File

@ -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<String> 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;
}
}

View File

@ -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;
}
}
}

View File

@ -16,6 +16,7 @@
<Button text="Forward" onAction="#goForward"/>
<Button text="Accounts" onAction="#goToAccounts"/>
<Button text="Transactions" onAction="#goToTransactions"/>
<Button text="Profiles" onAction="#viewProfiles"/>
</HBox>
<HBox fx:id="breadcrumbHBox" styleClass="std-spacing,small-text"/>
</VBox>

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.text.*?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.ProfilesViewController"
stylesheets="@style/base.css"
prefWidth="500"
prefHeight="400"
>
<top>
<VBox styleClass="std-padding,std-spacing">
<TextFlow>
<Text text="In Perfin, all your accounts, transactions, files, and other financial data are stored in a single "/>
<Text text="profile" styleClass="bold-text"/>
<Text text=". By default, Perfin uses the "/>
<Text text="default" style="-fx-font-style: italic;"/>
<Text text=" profile, and this should be sufficient for most users, but you can also add new profiles if you'd like to track some finances separately."/>
</TextFlow>
</VBox>
</top>
<center>
<ScrollPane fitToWidth="true" fitToHeight="true">
<VBox fx:id="profilesVBox" styleClass="std-padding,spacing-extra"/>
</ScrollPane>
</center>
<bottom>
<BorderPane>
<left>
<VBox styleClass="std-padding">
<Label text="Add New Profile"/>
</VBox>
</left>
<center>
<VBox styleClass="std-padding">
<TextField fx:id="newProfileNameField" style="-fx-min-width: 50px; -fx-pref-width: 50px;"/>
<Text
fx:id="newProfileNameErrorLabel"
styleClass="error-text"
style="-fx-fill: red;"
text="Invalid profile name. Profile names must only contain lowercase text."
/>
</VBox>
</center>
<right>
<VBox styleClass="std-padding">
<Button text="Add" onAction="#addProfile" fx:id="addProfileButton"/>
</VBox>
</right>
</BorderPane>
</bottom>
</BorderPane>