diff --git a/design/perfin-logo.svg b/design/perfin-logo.svg index 2dcc35f..0e953e2 100644 --- a/design/perfin-logo.svg +++ b/design/perfin-logo.svg @@ -26,9 +26,9 @@ inkscape:pagecheckerboard="1" inkscape:deskcolor="#505050" inkscape:document-units="px" - inkscape:zoom="5.6568543" - inkscape:cx="3.2703689" - inkscape:cy="25.102291" + inkscape:zoom="4" + inkscape:cx="-0.99999999" + inkscape:cy="38.25" inkscape:window-width="1920" inkscape:window-height="1025" inkscape:window-x="1080" @@ -54,25 +54,25 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1"> - - - - + + + + + + diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 9b578e2..54426b4 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -44,6 +44,7 @@ public class PerfinApp extends Application { splashScreen.showAndWait(); if (splashScreen.isStartupSuccessful()) { stage.show(); + stage.setMaximized(true); } } @@ -70,6 +71,7 @@ public class PerfinApp extends Application { mapResourceRoute("edit-account", "/edit-account.fxml"); mapResourceRoute("transactions", "/transactions-view.fxml"); mapResourceRoute("create-transaction", "/create-transaction.fxml"); + mapResourceRoute("create-balance-record", "/create-balance-record.fxml"); }); } diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index f3e51b0..886fdf0 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -54,6 +54,10 @@ public class AccountViewController implements RouteSelectionListener { router.navigate("edit-account", account); } + @FXML public void goToCreateBalanceRecord() { + router.navigate("create-balance-record", account); + } + @FXML public void archiveAccount() { var confirmResult = new Alert( diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java new file mode 100644 index 0000000..da48bed --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -0,0 +1,68 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.util.CurrencyUtil; +import com.andrewlalis.perfin.data.util.DateUtil; +import com.andrewlalis.perfin.data.util.FileUtil; +import com.andrewlalis.perfin.model.Account; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.view.component.FileSelectionArea; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.scene.control.TextField; +import javafx.scene.layout.VBox; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class CreateBalanceRecordController implements RouteSelectionListener { + @FXML public TextField timestampField; + @FXML public TextField balanceField; + @FXML public VBox attachmentsVBox; + private FileSelectionArea attachmentSelectionArea; + + private Account account; + + @FXML public void initialize() { + attachmentSelectionArea = new FileSelectionArea(FileUtil::newAttachmentsFileChooser, () -> attachmentsVBox.getScene().getWindow()); + attachmentSelectionArea.allowMultiple.set(true); + attachmentsVBox.getChildren().add(attachmentSelectionArea); + } + + @Override + public void onRouteSelected(Object context) { + this.account = (Account) context; + timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); + Thread.ofVirtual().start(() -> { + Profile.getCurrent().getDataSource().useAccountRepository(repo -> { + BigDecimal value = repo.deriveCurrentBalance(account.getId()); + Platform.runLater(() -> balanceField.setText( + CurrencyUtil.formatMoneyAsBasicNumber(value, account.getCurrency()) + )); + }); + }); + attachmentSelectionArea.clear(); + } + + @FXML public void save() { + // TODO: Add validation. + Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> { + LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); + BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); + repo.insert( + DateUtil.localToUTC(localTimestamp), + account.getId(), + reportedBalance, + account.getCurrency(), + attachmentSelectionArea.getSelectedFiles() + ); + }); + router.navigateBackAndClear(); + } + + @FXML public void cancel() { + router.navigateBackAndClear(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java index 0bd3429..c0a4ba6 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java @@ -2,22 +2,17 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.data.util.DateUtil; +import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.CreditAndDebitAccounts; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.view.AccountComboBoxCellFactory; -import com.andrewlalis.perfin.view.BindingUtil; +import com.andrewlalis.perfin.view.component.FileSelectionArea; import javafx.application.Platform; -import javafx.beans.property.SimpleListProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.*; -import javafx.scene.layout.AnchorPane; import javafx.scene.layout.VBox; -import javafx.stage.FileChooser; -import java.io.File; import java.math.BigDecimal; import java.nio.file.Path; import java.time.DateTimeException; @@ -45,9 +40,8 @@ public class CreateTransactionController implements RouteSelectionListener { @FXML public ComboBox linkCreditAccountComboBox; @FXML public Label linkedAccountsErrorLabel; - private final ObservableList selectedAttachmentFiles = FXCollections.observableArrayList(); - @FXML public VBox selectedFilesVBox; - @FXML public Label noSelectedFilesLabel; + @FXML public VBox attachmentsVBox; + private FileSelectionArea attachmentsSelectionArea; @FXML public void initialize() { // Setup error field validation. @@ -83,40 +77,13 @@ public class CreateTransactionController implements RouteSelectionListener { updateLinkAccountComboBoxes(newValue); }); - // Show the "no files selected" label when the list is empty. And sync the vbox with the selected files. - noSelectedFilesLabel.managedProperty().bind(noSelectedFilesLabel.visibleProperty()); - var filesListProp = new SimpleListProperty<>(selectedAttachmentFiles); - noSelectedFilesLabel.visibleProperty().bind(filesListProp.emptyProperty()); - BindingUtil.mapContent(selectedFilesVBox.getChildren(), selectedAttachmentFiles, file -> { - Label filenameLabel = new Label(file.getName()); - Button removeButton = new Button("Remove"); - removeButton.setOnAction(event -> { - selectedAttachmentFiles.remove(file); - }); - AnchorPane fileBox = new AnchorPane(filenameLabel, removeButton); - AnchorPane.setLeftAnchor(filenameLabel, 0.0); - AnchorPane.setRightAnchor(removeButton, 0.0); - return fileBox; - }); - } - - @FXML public void selectAttachmentFile() { - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Select Transaction Attachment(s)"); - fileChooser.getExtensionFilters().addAll( - new FileChooser.ExtensionFilter( - "Attachment Files", - "*.pdf", "*.docx", "*.odt", "*.html", "*.txt", "*.md", "*.xml", "*.json", - "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.bmp", "*.tiff" - ) + // Initialize the file selection area. + attachmentsSelectionArea = new FileSelectionArea( + FileUtil::newAttachmentsFileChooser, + () -> attachmentsVBox.getScene().getWindow() ); - List files = fileChooser.showOpenMultipleDialog(amountField.getScene().getWindow()); - if (files == null) return; - for (var file : files) { - if (selectedAttachmentFiles.stream().noneMatch(f -> !f.equals(file) && f.getName().equals(file.getName()))) { - selectedAttachmentFiles.add(file); - } - } + attachmentsSelectionArea.allowMultiple.set(true); + attachmentsVBox.getChildren().add(attachmentsSelectionArea); } @FXML public void save() { @@ -136,7 +103,7 @@ public class CreateTransactionController implements RouteSelectionListener { Currency currency = currencyChoiceBox.getValue(); String description = descriptionField.getText() == null ? null : descriptionField.getText().strip(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); - List attachments = selectedAttachmentFiles.stream().map(File::toPath).toList(); + List attachments = attachmentsSelectionArea.getSelectedFiles(); Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { repo.insert( utcTimestamp, @@ -164,7 +131,7 @@ public class CreateTransactionController implements RouteSelectionListener { timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); amountField.setText("0"); descriptionField.setText(null); - selectedAttachmentFiles.clear(); + attachmentsSelectionArea.clear(); Thread.ofVirtual().start(() -> { Profile.getCurrent().getDataSource().useAccountRepository(repo -> { var currencies = repo.findAllUsedCurrencies().stream() diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index 1ea948e..038a379 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -86,7 +86,10 @@ public class TransactionViewController { "Are you sure you want to delete this transaction? This will " + "permanently remove the transaction and its effects on any linked " + "accounts, as well as remove any attachments from storage within " + - "this app." + "this app.\n\n" + + "Note that incorrect or missing transactions can cause your " + + "account's balance to be incorrectly reported in Perfin, because " + + "it's derived from the most recent balance-record, and transactions." ); if (confirm) { Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java index 159a3ad..f3e9838 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java @@ -1,17 +1,24 @@ package com.andrewlalis.perfin.data; +import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.BalanceRecord; import com.andrewlalis.perfin.model.history.AccountHistoryItem; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface AccountHistoryItemRepository extends AutoCloseable { void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId); void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId); void recordText(LocalDateTime timestamp, long accountId, String text); List findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count); + default Optional getMostRecentForAccount(long accountId) { + var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1); + if (items.isEmpty()) return Optional.empty(); + return Optional.of(items.getFirst()); + } String getTextItem(long itemId); AccountEntry getAccountEntryItem(long itemId); BalanceRecord getBalanceRecordItem(long itemId); diff --git a/src/main/java/com/andrewlalis/perfin/data/util/CurrencyUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/CurrencyUtil.java index 9649461..3f7a1dc 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/CurrencyUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/CurrencyUtil.java @@ -14,4 +14,9 @@ public class CurrencyUtil { BigDecimal displayValue = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP); return nf.format(displayValue); } + + public static String formatMoneyAsBasicNumber(BigDecimal amount, Currency currency) { + BigDecimal displayValue = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP); + return displayValue.toString(); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java index 7e98fd5..8f811f7 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java @@ -1,5 +1,7 @@ package com.andrewlalis.perfin.data.util; +import javafx.stage.FileChooser; + import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -54,4 +56,17 @@ public class FileUtil { public static String getTypeSuffix(Path filePath) { return getTypeSuffix(filePath.getFileName().toString()); } + + public static FileChooser newAttachmentsFileChooser() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Select Attachments"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter( + "Attachment Files", + "*.pdf", "*.docx", "*.odt", "*.html", "*.txt", "*.md", "*.xml", "*.json", + "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.bmp", "*.tiff" + ) + ); + return fileChooser; + } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java index b4f56c7..20d90f2 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java @@ -42,11 +42,9 @@ public class AccountHistoryItemTile extends BorderPane { Text amountText = new Text(CurrencyUtil.formatMoney(entry.getSignedAmount(), entry.getCurrency())); Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId()); return new TextFlow( - new Text("Entry added with value of "), - amountText, - new Text(", linked with "), transactionLink, - new Text(".") + new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of"), + amountText ); } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/FileSelectionArea.java b/src/main/java/com/andrewlalis/perfin/view/component/FileSelectionArea.java new file mode 100644 index 0000000..57b063c --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/FileSelectionArea.java @@ -0,0 +1,96 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.view.BindingUtil; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ListProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Window; + +import java.io.File; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +/** + * A pane within which a user can select one or more files. + */ +public class FileSelectionArea extends VBox { + public final BooleanProperty allowMultiple = new SimpleBooleanProperty(false); + private final ObservableList selectedFiles = FXCollections.observableArrayList(); + + public FileSelectionArea(Supplier fileChooserSupplier, Supplier windowSupplier) { + getStyleClass().addAll("std-padding", "std-spacing"); + + VBox filesVBox = new VBox(); + filesVBox.getStyleClass().addAll("std-padding", "std-spacing"); + BindingUtil.mapContent(filesVBox.getChildren(), selectedFiles, this::buildFileItem); + ListProperty selectedFilesProperty = new SimpleListProperty<>(selectedFiles); + + Label noFilesLabel = new Label("No files selected."); + noFilesLabel.managedProperty().bind(noFilesLabel.visibleProperty()); + noFilesLabel.visibleProperty().bind(selectedFilesProperty.emptyProperty()); + + Button selectFilesButton = new Button("Select files"); + selectFilesButton.setOnAction(event -> onSelectFileClicked(fileChooserSupplier.get(), windowSupplier.get())); + selectFilesButton.disableProperty().bind( + allowMultiple.not().and(selectedFilesProperty.emptyProperty().not()) + ); + + getChildren().addAll( + filesVBox, + noFilesLabel, + selectFilesButton + ); + } + + public List getSelectedFiles() { + return Collections.unmodifiableList(selectedFiles); + } + + public void clear() { + selectedFiles.clear(); + } + + private Node buildFileItem(Path path) { + Label filenameLabel = new Label(path.getFileName().toString()); + filenameLabel.getStyleClass().addAll("mono-font"); + Button removeButton = new Button("Remove"); + removeButton.setOnAction(event -> selectedFiles.remove(path)); + AnchorPane pane = new AnchorPane(filenameLabel, removeButton); + AnchorPane.setLeftAnchor(filenameLabel, 0.0); + AnchorPane.setTopAnchor(filenameLabel, 0.0); + AnchorPane.setBottomAnchor(filenameLabel, 0.0); + + AnchorPane.setRightAnchor(removeButton, 0.0); + return pane; + } + + private void onSelectFileClicked(FileChooser fileChooser, Window owner) { + if (allowMultiple.get()) { + var files = fileChooser.showOpenMultipleDialog(owner); + if (files != null) { + for (File file : files) { + Path path = file.toPath(); + if (!selectedFiles.contains(path)) { + selectedFiles.add(path); + } + } + } + } else { + File file = fileChooser.showOpenDialog(owner); + if (file != null && !selectedFiles.contains(file.toPath())) { + selectedFiles.add(file.toPath()); + } + } + } +} diff --git a/src/main/resources/account-view.fxml b/src/main/resources/account-view.fxml index dab0c8b..fef1343 100644 --- a/src/main/resources/account-view.fxml +++ b/src/main/resources/account-view.fxml @@ -53,6 +53,7 @@