diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index de1d02d..f70b78a 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -5,7 +5,6 @@ import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.BalanceRecordRepository; 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.MoneyValue; import com.andrewlalis.perfin.model.Profile; @@ -32,7 +31,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { @FXML public TextField timestampField; @FXML public TextField balanceField; @FXML public Label balanceWarningLabel; - private FileSelectionArea attachmentSelectionArea; + @FXML public FileSelectionArea attachmentSelectionArea; @FXML public PropertiesPane propertiesPane; @FXML public Button saveButton; @@ -71,14 +70,6 @@ public class CreateBalanceRecordController implements RouteSelectionListener { var formValid = timestampValid.and(balanceValid); saveButton.disableProperty().bind(formValid.not()); - - // Manually append the attachment selection area to the end of the properties pane. - attachmentSelectionArea = new FileSelectionArea( - FileUtil::newAttachmentsFileChooser, - () -> timestampField.getScene().getWindow() - ); - attachmentSelectionArea.allowMultiple.set(true); - propertiesPane.getChildren().addLast(attachmentSelectionArea); } @Override @@ -110,7 +101,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { account.id, reportedBalance, account.getCurrency(), - attachmentSelectionArea.getSelectedFiles() + attachmentSelectionArea.getSelectedPaths() ); }); router.navigateBackAndClear(); diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index e111a6e..c708fe0 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -6,10 +6,7 @@ import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.Sort; 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.CreditAndDebitAccounts; -import com.andrewlalis.perfin.model.Profile; -import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; @@ -20,8 +17,8 @@ import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.control.*; +import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; -import javafx.scene.layout.VBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,6 +27,7 @@ import java.nio.file.Path; import java.time.DateTimeException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.Collections; import java.util.Comparator; import java.util.Currency; import java.util.List; @@ -39,6 +37,7 @@ import static com.andrewlalis.perfin.PerfinApp.router; public class EditTransactionController implements RouteSelectionListener { private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class); + @FXML public BorderPane container; @FXML public Label titleLabel; @FXML public TextField timestampField; @@ -50,8 +49,7 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public AccountSelectionBox debitAccountSelector; @FXML public AccountSelectionBox creditAccountSelector; - @FXML public VBox attachmentsVBox; - private FileSelectionArea attachmentsSelectionArea; + @FXML public FileSelectionArea attachmentsSelectionArea; @FXML public Button saveButton; @@ -85,45 +83,62 @@ public class EditTransactionController implements RouteSelectionListener { ) .addPredicate( accounts -> ( - (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) || + (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) && (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue())) ), "Linked accounts must use the same currency." ) + .addPredicate( + accounts -> ( + (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) && + (!accounts.hasDebit() || !accounts.debitAccount().isArchived()) + ), + "Linked accounts must not be archived." + ) ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); saveButton.disableProperty().bind(formValid.not()); - - // Initialize the file selection area. - attachmentsSelectionArea = new FileSelectionArea( - FileUtil::newAttachmentsFileChooser, - () -> attachmentsVBox.getScene().getWindow() - ); - attachmentsSelectionArea.allowMultiple.set(true); - attachmentsVBox.getChildren().add(attachmentsSelectionArea); } @FXML public void save() { + LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp()); + BigDecimal amount = new BigDecimal(amountField.getText()); + Currency currency = currencyChoiceBox.getValue(); + String description = getSanitizedDescription(); + CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); + List newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths(); + List existingAttachments = attachmentsSelectionArea.getSelectedAttachments(); final long idToNavigate; if (transaction == null) { - LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp()); - BigDecimal amount = new BigDecimal(amountField.getText()); - Currency currency = currencyChoiceBox.getValue(); - String description = getSanitizedDescription(); - CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); - List attachments = attachmentsSelectionArea.getSelectedFiles(); - Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> { - repo.insert( + idToNavigate = Profile.getCurrent().getDataSource().mapRepo( + TransactionRepository.class, + repo -> repo.insert( + utcTimestamp, + amount, + currency, + description, + linkedAccounts, + newAttachmentPaths + ) + ); + } else { + Profile.getCurrent().getDataSource().useRepo( + TransactionRepository.class, + repo -> repo.update( + transaction.id, utcTimestamp, amount, currency, description, linkedAccounts, - attachments - ); - }); + existingAttachments, + newAttachmentPaths + ) + ); + idToNavigate = transaction.id; } + router.replace("transactions", new TransactionsViewController.RouteContext(idToNavigate)); } @FXML public void cancel() { @@ -133,57 +148,58 @@ public class EditTransactionController implements RouteSelectionListener { @Override public void onRouteSelected(Object context) { transaction = (Transaction) context; - boolean creatingNew = transaction == null; - if (creatingNew) { + if (transaction == null) { titleLabel.setText("Create New Transaction"); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); amountField.setText(null); descriptionField.setText(null); - attachmentsSelectionArea.clear(); } else { titleLabel.setText("Edit Transaction #" + transaction.id); timestampField.setText(DateUtil.formatUTCAsLocal(transaction.getTimestamp())); amountField.setText(CurrencyUtil.formatMoneyAsBasicNumber(transaction.getMoneyAmount())); descriptionField.setText(transaction.getDescription()); - - // TODO: Add an editable list of attachments from which some can be added and removed. - } // Fetch some account-specific data. - currencyChoiceBox.setDisable(true); - creditAccountSelector.setDisable(true); - debitAccountSelector.setDisable(true); + container.setDisable(true); Thread.ofVirtual().start(() -> { try ( var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository(); var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository() ) { - var currencies = accountRepo.findAllUsedCurrencies().stream() + // First fetch all the data. + List currencies = accountRepo.findAllUsedCurrencies().stream() .sorted(Comparator.comparing(Currency::getCurrencyCode)) .toList(); - var accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); - CreditAndDebitAccounts linkedAccounts = transaction == null ? null : transactionRepo.findLinkedAccounts(transaction.id); + List accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); + final List attachments; + final CreditAndDebitAccounts linkedAccounts; + if (transaction == null) { + attachments = Collections.emptyList(); + linkedAccounts = new CreditAndDebitAccounts(null, null); + } else { + attachments = transactionRepo.findAttachments(transaction.id); + linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id); + } + // Then make updates to the view. Platform.runLater(() -> { creditAccountSelector.setAccounts(accounts); debitAccountSelector.setAccounts(accounts); currencyChoiceBox.getItems().setAll(currencies); - if (creatingNew) { + attachmentsSelectionArea.clear(); + attachmentsSelectionArea.addAttachments(attachments); + if (transaction == null) { // TODO: Allow user to select a default currency. currencyChoiceBox.getSelectionModel().selectFirst(); creditAccountSelector.select(null); debitAccountSelector.select(null); } else { currencyChoiceBox.getSelectionModel().select(transaction.getCurrency()); - if (linkedAccounts != null) { - creditAccountSelector.select(linkedAccounts.creditAccount()); - debitAccountSelector.select(linkedAccounts.debitAccount()); - } + creditAccountSelector.select(linkedAccounts.creditAccount()); + debitAccountSelector.select(linkedAccounts.debitAccount()); } - currencyChoiceBox.setDisable(false); - creditAccountSelector.setDisable(false); - debitAccountSelector.setDisable(false); + container.setDisable(false); }); } catch (Exception e) { log.error("Failed to get repositories.", e); diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index be8feb2..ca2181e 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -49,14 +49,18 @@ public class TransactionViewController { CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id); List attachments = repo.findAttachments(transaction.id); Platform.runLater(() -> { - accounts.ifDebit(acc -> { - debitAccountLink.setText(acc.getShortName()); - debitAccountLink.setOnAction(event -> router.navigate("account", acc)); - }); - accounts.ifCredit(acc -> { - creditAccountLink.setText(acc.getShortName()); - creditAccountLink.setOnAction(event -> router.navigate("account", acc)); - }); + if (accounts.hasDebit()) { + debitAccountLink.setText(accounts.debitAccount().getShortName()); + debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount())); + } else { + debitAccountLink.setText(null); + } + if (accounts.hasCredit()) { + creditAccountLink.setText(accounts.creditAccount().getShortName()); + creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount())); + } else { + creditAccountLink.setText(null); + } attachmentsViewPane.setAttachments(attachments); }); }); diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index 10a9618..bb00272 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -126,12 +126,9 @@ public class TransactionsViewController implements RouteSelectionListener { // Refresh account filter options. Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { List accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); - accounts.add(null); Platform.runLater(() -> { - filterByAccountComboBox.getItems().clear(); - filterByAccountComboBox.getItems().addAll(accounts); - filterByAccountComboBox.getSelectionModel().selectLast(); - filterByAccountComboBox.getButtonCell().updateIndex(accounts.size() - 1); + filterByAccountComboBox.setAccounts(accounts); + filterByAccountComboBox.select(null); }); }); diff --git a/src/main/java/com/andrewlalis/perfin/data/Repository.java b/src/main/java/com/andrewlalis/perfin/data/Repository.java index a91996a..472cf94 100644 --- a/src/main/java/com/andrewlalis/perfin/data/Repository.java +++ b/src/main/java/com/andrewlalis/perfin/data/Repository.java @@ -1,4 +1,6 @@ package com.andrewlalis.perfin.data; -public interface Repository { -} +/** + * Marker interface used to identify any data repository. + */ +public interface Repository {} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index e6943fc..08003cd 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -32,4 +32,14 @@ public interface TransactionRepository extends Repository, AutoCloseable { CreditAndDebitAccounts findLinkedAccounts(long transactionId); List findAttachments(long transactionId); void delete(long transactionId); + void update( + long id, + LocalDateTime utcTimestamp, + BigDecimal amount, + Currency currency, + String description, + CreditAndDebitAccounts linkedAccounts, + List existingAttachments, + List newAttachmentPaths + ); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java index 70ddd59..429f62a 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAttachmentRepository.java @@ -60,8 +60,8 @@ public record JdbcAttachmentRepository(Connection conn, Path contentDir) impleme public void deleteById(long attachmentId) { var optionalAttachment = findById(attachmentId); if (optionalAttachment.isPresent()) { - deleteFileOnDisk(optionalAttachment.get()); DbUtil.updateOne(conn, "DELETE FROM attachment WHERE id = ?", List.of(attachmentId)); + deleteFileOnDisk(optionalAttachment.get()); } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index fefa1e3..5eb9a8d 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -5,10 +5,13 @@ import com.andrewlalis.perfin.data.AttachmentRepository; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.util.CurrencyUtil; +import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.*; import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.file.Path; import java.sql.Connection; import java.sql.ResultSet; @@ -40,13 +43,9 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency)); // 3. Add attachments. AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); - try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) { - for (var attachmentPath : attachments) { - Attachment attachment = attachmentRepo.insert(attachmentPath); - // Insert the link-table entry. - DbUtil.setArgs(stmt, txId, attachment.id); - stmt.executeUpdate(); - } + for (Path attachmentPath : attachments) { + Attachment attachment = attachmentRepo.insert(attachmentPath); + insertAttachmentLink(txId, attachment.id); } return txId; }); @@ -150,10 +149,78 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem @Override public void delete(long transactionId) { - DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId)); + DbUtil.doTransaction(conn, () -> { + DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId)); + DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(transactionId)); + }); new JdbcAttachmentRepository(conn, contentDir).deleteAllOrphans(); } + @Override + public void update( + long id, + LocalDateTime utcTimestamp, + BigDecimal amount, + Currency currency, + String description, + CreditAndDebitAccounts linkedAccounts, + List existingAttachments, + List newAttachmentPaths + ) { + DbUtil.doTransaction(conn, () -> { + Transaction tx = findById(id).orElseThrow(); + CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id); + List currentAttachments = findAttachments(id); + var entryRepo = new JdbcAccountEntryRepository(conn); + var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); + List updateMessages = new ArrayList<>(); + if (!tx.getTimestamp().equals(utcTimestamp)) { + DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id)); + updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + "."); + } + BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP); + if (!tx.getAmount().equals(scaledAmount)) { + DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id)); + updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + "."); + } + if (!tx.getCurrency().equals(currency)) { + DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id)); + updateMessages.add("Updated currency to " + currency.getCurrencyCode() + "."); + } + if (!Objects.equals(tx.getDescription(), description)) { + DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id)); + updateMessages.add("Updated description."); + } + boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) || + !tx.getCurrency().equals(currency) || + !tx.getTimestamp().equals(utcTimestamp) || + !currentLinkedAccounts.equals(linkedAccounts); + if (updateAccountEntries) { + // Delete all entries and re-write them correctly? + DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id)); + linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency)); + linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency)); + updateMessages.add("Updated linked accounts."); + } + // Manage attachments changes. + List removedAttachments = new ArrayList<>(currentAttachments); + removedAttachments.removeAll(existingAttachments); + for (Attachment removedAttachment : removedAttachments) { + attachmentRepo.deleteById(removedAttachment.id); + updateMessages.add("Removed attachment \"" + removedAttachment.getFilename() + "\"."); + } + for (Path attachmentPath : newAttachmentPaths) { + Attachment attachment = attachmentRepo.insert(attachmentPath); + insertAttachmentLink(tx.id, attachment.id); + updateMessages.add("Added attachment \"" + attachment.getFilename() + "\"."); + } + String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages); + var historyRepo = new JdbcAccountHistoryItemRepository(conn); + linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); + linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); + }); + } + @Override public void close() throws Exception { conn.close(); @@ -168,4 +235,12 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem rs.getString("description") ); } + + private void insertAttachmentLink(long transactionId, long attachmentId) { + DbUtil.insertOne( + conn, + "INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)", + List.of(transactionId, attachmentId) + ); + } } 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 1d276e5..0edab5c 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; import java.util.HashMap; import java.util.Map; @@ -85,4 +86,21 @@ public class FileUtil { ); return fileChooser; } + + public static byte[] getHash(Path path) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[4096]; + try (var in = Files.newInputStream(path)) { + int count = in.read(buffer); + while (count != -1) { + md.update(buffer, 0, count); + count = in.read(buffer); + } + } + return md.digest(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentsViewPane.java b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentsViewPane.java index a6c384b..e06546e 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentsViewPane.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentsViewPane.java @@ -50,6 +50,7 @@ public class AttachmentsViewPane extends VBox { } public void setAttachments(List attachments) { + this.attachments.clear(); this.attachments.setAll(attachments); } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/FileSelectionArea.java b/src/main/java/com/andrewlalis/perfin/view/component/FileSelectionArea.java index 53a77f6..9ae86fa 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/FileSelectionArea.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/FileSelectionArea.java @@ -1,10 +1,9 @@ package com.andrewlalis.perfin.view.component; +import com.andrewlalis.perfin.data.util.FileUtil; +import com.andrewlalis.perfin.model.Attachment; 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.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Node; @@ -17,33 +16,55 @@ import javafx.stage.Window; import java.io.File; import java.nio.file.Path; -import java.util.Collections; +import java.util.ArrayList; 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(); + interface FileItem { + String getName(); + } - public FileSelectionArea(Supplier fileChooserSupplier, Supplier windowSupplier) { + public record PathItem(Path path) implements FileItem { + @Override + public String getName() { + return path.getFileName().toString(); + } + } + + public record AttachmentItem(Attachment attachment) implements FileItem { + @Override + public String getName() { + return attachment.getFilename(); + } + } + + private final BooleanProperty allowMultiple = new SimpleBooleanProperty(false); + + private final ObservableList selectedFiles = FXCollections.observableArrayList(); + private final ObjectProperty fileChooserProperty = new SimpleObjectProperty<>(getDefaultFileChooser()); + + public FileSelectionArea() { 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); + 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.setOnAction(event -> { + onSelectFileClicked(fileChooserProperty.get(), getScene().getWindow()); + }); selectFilesButton.disableProperty().bind( allowMultiple.not().and(selectedFilesProperty.emptyProperty().not()) + .or(fileChooserProperty.isNull()) ); getChildren().addAll( @@ -53,23 +74,48 @@ public class FileSelectionArea extends VBox { ); } - public List getSelectedFiles() { - return Collections.unmodifiableList(selectedFiles); + public List getSelectedAttachments() { + List attachments = new ArrayList<>(); + for (FileItem item : selectedFiles) { + if (item instanceof AttachmentItem a) { + attachments.add(a.attachment()); + } + } + return attachments; + } + + public List getSelectedPaths() { + List paths = new ArrayList<>(); + for (FileItem item : selectedFiles) { + if (item instanceof PathItem p) { + paths.add(p.path()); + } + } + return paths; } public void clear() { selectedFiles.clear(); } - public void setSelectedFiles(List files) { + public void addAttachments(List attachments) { + for (Attachment attachment : attachments) { + FileItem item = new AttachmentItem(attachment); + if (!selectedFiles.contains(item)) { + selectedFiles.add(item); + } + } + } + + public void setSelectedFiles(List files) { selectedFiles.setAll(files); } - private Node buildFileItem(Path path) { - Label filenameLabel = new Label(path.getFileName().toString()); + private Node buildFileItem(FileItem item) { + Label filenameLabel = new Label(item.getName()); filenameLabel.getStyleClass().addAll("mono-font"); Button removeButton = new Button("Remove"); - removeButton.setOnAction(event -> selectedFiles.remove(path)); + removeButton.setOnAction(event -> selectedFiles.remove(item)); AnchorPane pane = new AnchorPane(filenameLabel, removeButton); AnchorPane.setLeftAnchor(filenameLabel, 0.0); AnchorPane.setTopAnchor(filenameLabel, 0.0); @@ -84,17 +130,35 @@ public class FileSelectionArea extends VBox { var files = fileChooser.showOpenMultipleDialog(owner); if (files != null) { for (File file : files) { - Path path = file.toPath(); - if (!selectedFiles.contains(path)) { - selectedFiles.add(path); + FileItem item = new PathItem(file.toPath()); + if (!selectedFiles.contains(item)) { + selectedFiles.add(item); } } } } else { File file = fileChooser.showOpenDialog(owner); - if (file != null && !selectedFiles.contains(file.toPath())) { - selectedFiles.add(file.toPath()); + FileItem item = file == null ? null : new PathItem(file.toPath()); + if (item != null && !selectedFiles.contains(item)) { + selectedFiles.add(item); } } } + + private FileChooser getDefaultFileChooser() { + return FileUtil.newAttachmentsFileChooser(); + } + + // Property methods. + public final BooleanProperty allowMultipleProperty() { + return allowMultiple; + } + + public final boolean getAllowMultiple() { + return allowMultiple.get(); + } + + public final void setAllowMultiple(boolean value) { + allowMultiple.set(value); + } } diff --git a/src/main/resources/create-balance-record.fxml b/src/main/resources/create-balance-record.fxml index ce35fec..98683f4 100644 --- a/src/main/resources/create-balance-record.fxml +++ b/src/main/resources/create-balance-record.fxml @@ -1,5 +1,6 @@ + @@ -50,6 +51,7 @@