diff --git a/src/main/java/com/andrewlalis/perfin/Pair.java b/src/main/java/com/andrewlalis/perfin/Pair.java new file mode 100644 index 0000000..85cd786 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/Pair.java @@ -0,0 +1,7 @@ +package com.andrewlalis.perfin; + +public record Pair(A first, B second) { + public static Pair of(A first, B second) { + return new Pair<>(first, second); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/SceneUtil.java b/src/main/java/com/andrewlalis/perfin/SceneUtil.java index 736b4b2..e7c2e8f 100644 --- a/src/main/java/com/andrewlalis/perfin/SceneUtil.java +++ b/src/main/java/com/andrewlalis/perfin/SceneUtil.java @@ -10,6 +10,17 @@ import java.net.URL; import java.util.function.Consumer; public class SceneUtil { + public static Pair loadNodeAndController(String fxml) { + FXMLLoader loader = new FXMLLoader(SceneUtil.class.getResource(fxml)); + try { + N node = loader.load(); + C controller = loader.getController(); + return Pair.of(node, controller); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + public static Parent loadNode(String fxml, Consumer controllerConfig) { FXMLLoader loader = new FXMLLoader(SceneUtil.class.getResource(fxml)); try { diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index 1f104b7..19b374a 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -1,6 +1,5 @@ package com.andrewlalis.perfin.control; -import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.data.CurrencyUtil; import com.andrewlalis.perfin.data.DateUtil; import com.andrewlalis.perfin.model.CreditAndDebitAccounts; @@ -23,7 +22,7 @@ import java.util.List; import static com.andrewlalis.perfin.PerfinApp.router; -public class TransactionViewController implements RouteSelectionListener { +public class TransactionViewController { private Transaction transaction; @FXML public Label amountLabel; @@ -37,9 +36,9 @@ public class TransactionViewController implements RouteSelectionListener { @FXML public HBox attachmentsHBox; private final ObservableList attachmentsList = FXCollections.observableArrayList(); - @Override - public void onRouteSelected(Object context) { - this.transaction = (Transaction) context; + public void setTransaction(Transaction transaction) { + this.transaction = transaction; + if (transaction == null) return; amountLabel.setText(CurrencyUtil.formatMoney(transaction.getAmount(), transaction.getCurrency())); timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); descriptionLabel.setText(transaction.getDescription()); @@ -75,6 +74,7 @@ public class TransactionViewController implements RouteSelectionListener { new Label(attachment.getFilename()), new Label(attachment.getContentType()) ); + // TODO: Custom attachment preview element. return vbox; }); } diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index 050dc44..6407eb2 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -1,23 +1,82 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.Pair; +import com.andrewlalis.perfin.SceneUtil; +import com.andrewlalis.perfin.control.component.DataSourcePaginationControls; import com.andrewlalis.perfin.control.component.TransactionTile; +import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.Transaction; import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ObservableValue; import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import static com.andrewlalis.perfin.PerfinApp.router; public class TransactionsViewController implements RouteSelectionListener { - @FXML - public VBox transactionsVBox; + @FXML public BorderPane transactionsListBorderPane; + @FXML public VBox transactionsVBox; + private DataSourcePaginationControls paginationControls; + + + @FXML public VBox detailPanel; + private final ObjectProperty selectedTransaction = new SimpleObjectProperty<>(null); + + @FXML public void initialize() { + // Initialize the left-hand paginated transactions list. + this.paginationControls = new DataSourcePaginationControls( + transactionsVBox.getChildren(), + new DataSourcePaginationControls.PageFetcherFunction() { + @Override + public Page fetchPage(PageRequest pagination) throws Exception { + try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { + return repo.findAll(pagination).map(TransactionsViewController.this::makeTile); + } + } + + @Override + public int getTotalCount() throws Exception { + try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { + return (int) repo.countAll(); + } + } + } + ); + transactionsListBorderPane.setBottom(paginationControls); + + // Initialize the right-hand transaction detail view. + HBox container = (HBox) detailPanel.getParent(); + ObservableValue halfWidthProp = container.widthProperty().map(n -> n.doubleValue() * 0.5); + detailPanel.minWidthProperty().bind(halfWidthProp); + detailPanel.maxWidthProperty().bind(halfWidthProp); + detailPanel.prefWidthProperty().bind(halfWidthProp); + detailPanel.managedProperty().bind(detailPanel.visibleProperty()); + detailPanel.visibleProperty().bind(selectedTransaction.isNotNull()); + + Pair detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml"); + TransactionViewController transactionViewController = detailComponents.second(); + BorderPane transactionDetailView = detailComponents.first(); + transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty()); + transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull()); + detailPanel.getChildren().add(transactionDetailView); + selectedTransaction.addListener((observable, oldValue, newValue) -> { + transactionViewController.setTransaction(newValue); + }); + } @Override public void onRouteSelected(Object context) { - refreshTransactions(); + paginationControls.sorts.setAll(Sort.desc("timestamp")); + this.paginationControls.setPage(1); } @FXML @@ -25,13 +84,37 @@ public class TransactionsViewController implements RouteSelectionListener { router.navigate("create-transaction"); } - private void refreshTransactions() { - Thread.ofVirtual().start(() -> { - Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { - var page = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp"))); - var components = page.items().stream().map(transaction -> new TransactionTile(transaction, this::refreshTransactions)).toList(); - Platform.runLater(() -> transactionsVBox.getChildren().setAll(components)); - }); + private TransactionTile makeTile(Transaction transaction) { + var tile = new TransactionTile(transaction); + tile.setOnMouseClicked(event -> { + if (selectedTransaction.get() == null || selectedTransaction.get().getId() != transaction.getId()) { + selectedTransaction.set(transaction); + } else { + selectedTransaction.set(null); + } }); + tile.selected.bind(selectedTransaction.map(t -> t != null && t.getId() == transaction.getId())); + return tile; } + +// private void refreshTransactions() { +// Thread.ofVirtual().start(() -> { +// Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { +// var page = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp"))); +// var components = page.items().stream().map(transaction -> { +// var tile = new TransactionTile(transaction, this::refreshTransactions); +// tile.setOnMouseClicked(event -> { +// if (selectedTransaction.get() == null || selectedTransaction.get().getId() != transaction.getId()) { +// selectedTransaction.set(transaction); +// } else { +// selectedTransaction.set(null); +// } +// }); +// tile.selected.bind(selectedTransaction.map(t -> t != null && t.getId() == transaction.getId())); +// return tile; +// }).toList(); +// Platform.runLater(() -> transactionsVBox.getChildren().setAll(components)); +// }); +// }); +// } } diff --git a/src/main/java/com/andrewlalis/perfin/control/component/DataSourcePaginationControls.java b/src/main/java/com/andrewlalis/perfin/control/component/DataSourcePaginationControls.java new file mode 100644 index 0000000..dc07992 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/component/DataSourcePaginationControls.java @@ -0,0 +1,96 @@ +package com.andrewlalis.perfin.control.component; + +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.pagination.Sort; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +/** + * A pane that contains some controls for navigating a paginated data source. + * That includes going to the next/previous page, setting the preferred page + * size. + */ +public class DataSourcePaginationControls extends BorderPane { + public interface PageFetcherFunction { + Page fetchPage(PageRequest pagination) throws Exception; + default int getTotalCount() throws Exception { + return -1; + } + } + + public final IntegerProperty currentPage = new SimpleIntegerProperty(1); + public final IntegerProperty maxPages = new SimpleIntegerProperty(-1); + public final IntegerProperty itemsPerPage = new SimpleIntegerProperty(5); + public final ObservableList sorts = FXCollections.observableArrayList(); + private final BooleanProperty fetching = new SimpleBooleanProperty(false); + private final ObservableList target; + private final PageFetcherFunction fetcher; + + public DataSourcePaginationControls(ObservableList target, PageFetcherFunction fetcher) { + this.target = target; + this.fetcher = fetcher; + + Text currentPageLabel = new Text(); + currentPageLabel.textProperty().bind(currentPage.asString()); + Text maxPagesLabel = new Text(); + maxPagesLabel.textProperty().bind(maxPages.asString()); + TextFlow maxPagesText = new TextFlow(new Text(" / "), maxPagesLabel); + maxPagesText.managedProperty().bind(maxPagesText.visibleProperty()); + maxPagesText.visibleProperty().bind(maxPages.isNotEqualTo(-1)); + TextFlow pageText = new TextFlow(new Text("Page "), currentPageLabel, maxPagesText); + + + Button previousPageButton = new Button("Previous Page"); + previousPageButton.disableProperty().bind(currentPage.lessThan(2).or(fetching)); + previousPageButton.setOnAction(event -> setPage(currentPage.get() - 1)); + Button nextPageButton = new Button("Next Page"); + nextPageButton.disableProperty().bind(fetching.or(currentPage.greaterThanOrEqualTo(maxPages))); + nextPageButton.setOnAction(event -> setPage(currentPage.get() + 1)); + + sorts.addListener((ListChangeListener) c -> { + setPage(1); + }); + + HBox hbox = new HBox( + previousPageButton, + pageText, + nextPageButton + ); + setCenter(hbox); + } + + public void setPage(int page) { + try { + fetching.set(true); + PageRequest pagination = new PageRequest(page - 1, itemsPerPage.get(), sorts); + var p = fetcher.fetchPage(pagination); + int totalResults = fetcher.getTotalCount(); + target.setAll(p.items()); + if (totalResults != -1) { + int max = totalResults / itemsPerPage.get(); + if (totalResults % itemsPerPage.get() != 0) { + max += 1; + } + maxPages.set(max); + } + currentPage.set(page); + } catch (Exception e) { + target.clear(); + e.printStackTrace(System.err); + } finally { + fetching.set(false); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/component/TransactionTile.java b/src/main/java/com/andrewlalis/perfin/control/component/TransactionTile.java index fe7a1c5..5d4c874 100644 --- a/src/main/java/com/andrewlalis/perfin/control/component/TransactionTile.java +++ b/src/main/java/com/andrewlalis/perfin/control/component/TransactionTile.java @@ -2,20 +2,21 @@ package com.andrewlalis.perfin.control.component; import com.andrewlalis.perfin.data.CurrencyUtil; import com.andrewlalis.perfin.data.DateUtil; -import com.andrewlalis.perfin.model.*; +import com.andrewlalis.perfin.model.CreditAndDebitAccounts; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.Transaction; import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.scene.Node; -import javafx.scene.control.Alert; -import javafx.scene.control.ButtonType; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; -import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; -import javafx.util.Pair; import java.util.concurrent.CompletableFuture; @@ -25,21 +26,32 @@ import static com.andrewlalis.perfin.PerfinApp.router; * A tile that displays a transaction's basic information. */ public class TransactionTile extends BorderPane { - public TransactionTile(Transaction transaction, Runnable refresh) { - setStyle(""" - -fx-border-color: lightgray; - -fx-border-width: 1px; - -fx-border-style: solid; - -fx-border-radius: 5px; - -fx-padding: 5px; - -fx-max-width: 500px; - -fx-cursor: hand; - """); + public final BooleanProperty selected = new SimpleBooleanProperty(false); + private static final String UNSELECTED_STYLE = """ + -fx-border-color: lightgray; + -fx-border-width: 1px; + -fx-border-style: solid; + -fx-border-radius: 5px; + -fx-padding: 5px; + -fx-cursor: hand; + """; + private static final String SELECTED_STYLE = """ + -fx-border-color: white; + -fx-border-width: 1px; + -fx-border-style: solid; + -fx-border-radius: 5px; + -fx-padding: 5px; + -fx-cursor: hand; + """; + + public TransactionTile(Transaction transaction) { + setStyle(UNSELECTED_STYLE); setTop(getHeader(transaction)); setCenter(getBody(transaction)); - setBottom(getFooter(transaction, refresh)); - addEventHandler(MouseEvent.MOUSE_CLICKED, event -> router.navigate("transaction", transaction)); + setBottom(getFooter(transaction)); + + styleProperty().bind(selected.map(value -> value ? SELECTED_STYLE : UNSELECTED_STYLE)); } private Node getHeader(Transaction transaction) { @@ -64,31 +76,23 @@ public class TransactionTile extends BorderPane { accounts.ifCredit(acc -> { Hyperlink link = new Hyperlink(acc.getShortName()); link.setOnAction(event -> router.navigate("account", acc)); - TextFlow text = new TextFlow(new Text("Credited from"), link); - Platform.runLater(() -> bodyVBox.getChildren().add(text)); + Text prefix = new Text("Credited from"); + prefix.setFill(Color.RED); + Platform.runLater(() -> bodyVBox.getChildren().add(new TextFlow(prefix, link))); }); accounts.ifDebit(acc -> { Hyperlink link = new Hyperlink(acc.getShortName()); link.setOnAction(event -> router.navigate("account", acc)); - TextFlow text = new TextFlow(new Text("Debited to"), link); - Platform.runLater(() -> bodyVBox.getChildren().add(text)); + Text prefix = new Text("Debited to"); + prefix.setFill(Color.GREEN); + Platform.runLater(() -> bodyVBox.getChildren().add(new TextFlow(prefix, link))); }); }); return bodyVBox; } - private Node getFooter(Transaction transaction, Runnable refresh) { + private Node getFooter(Transaction transaction) { Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); - Hyperlink deleteLink = new Hyperlink("Delete this transaction"); - deleteLink.setOnAction(event -> { - var confirmResult = new Alert(Alert.AlertType.CONFIRMATION, "Are you sure you want to delete this transaction?").showAndWait(); - if (confirmResult.isPresent() && confirmResult.get() == ButtonType.OK) { - Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { - repo.delete(transaction.getId()); - }); - refresh.run(); - } - }); HBox footerHBox = new HBox( timestampLabel ); diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index eb63994..43e07d1 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -12,6 +12,7 @@ public interface TransactionRepository extends AutoCloseable { long insert(Transaction transaction, Map accountsMap); void addAttachments(long transactionId, List attachments); Page findAll(PageRequest pagination); + long countAll(); Page findAllByAccounts(Set accountIds, PageRequest pagination); Map findEntriesWithAccounts(long transactionId); CreditAndDebitAccounts findLinkedAccounts(long transactionId); 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 38f87cc..a5f812e 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -91,6 +91,11 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR ); } + @Override + public long countAll() { + return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L); + } + @Override public Page findAllByAccounts(Set accountIds, PageRequest pagination) { String idsStr = accountIds.stream().map(String::valueOf).collect(Collectors.joining(",")); diff --git a/src/main/java/com/andrewlalis/perfin/data/pagination/Page.java b/src/main/java/com/andrewlalis/perfin/data/pagination/Page.java index 8154eab..df38600 100644 --- a/src/main/java/com/andrewlalis/perfin/data/pagination/Page.java +++ b/src/main/java/com/andrewlalis/perfin/data/pagination/Page.java @@ -1,5 +1,15 @@ package com.andrewlalis.perfin.data.pagination; import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; -public record Page(List items, PageRequest pagination) {} +public record Page(List items, PageRequest pagination) { + public Stream stream() { + return items.stream(); + } + + public Page map(Function mapper) { + return new Page<>(items.stream().map(mapper).toList(), pagination); + } +} diff --git a/src/main/resources/style/base.css b/src/main/resources/style/base.css index 9034f01..99486cc 100644 --- a/src/main/resources/style/base.css +++ b/src/main/resources/style/base.css @@ -26,3 +26,24 @@ .std-spacing { -fx-spacing: 3px; } + +.spacing-extra { + -fx-spacing: 6px; +} + +/* DEBUG BORDERS */ +.debug-border-1 { + -fx-border-color: red; +} + +.debug-border-2 { + -fx-border-color: lime; +} + +.debug-border-3 { + -fx-border-color: blue; +} + +.debug-border-4 { + -fx-border-color: magenta; +} diff --git a/src/main/resources/transaction-view.fxml b/src/main/resources/transaction-view.fxml index 543eb62..beecfec 100644 --- a/src/main/resources/transaction-view.fxml +++ b/src/main/resources/transaction-view.fxml @@ -8,49 +8,44 @@ xmlns:fx="http://javafx.com/fxml" fx:controller="com.andrewlalis.perfin.control.TransactionViewController" > - - - -
- - -
- - -