From 21f642bb5cd4261c0a6e9be5341758946968f329 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Sun, 21 Jun 2020 17:15:46 +0200 Subject: [PATCH] wallet transactions pane and wallet change events --- drongo | 2 +- .../sparrow/control/AddressTreeTable.java | 270 +----------------- .../sparrow/control/AmountCell.java | 53 ++++ .../sparrow/control/EntryCell.java | 199 +++++++++++++ .../sparrow/control/LabelCell.java | 87 ++++++ .../control/TransactionsTreeTable.java | 68 +++++ .../event/WalletBlockHeightChangedEvent.java | 6 +- .../sparrow/event/WalletChangedEvent.java | 3 + .../event/WalletHistoryChangedEvent.java | 4 + .../event/WalletNodesChangedEvent.java | 19 ++ .../event/WalletSettingsChangedEvent.java | 6 + .../sparrow/wallet/AddressesController.java | 28 +- .../sparrow/wallet/HashIndexEntry.java | 48 +++- .../sparrow/wallet/NodeEntry.java | 2 +- .../sparrow/wallet/SettingsController.java | 2 - .../sparrow/wallet/SettingsWalletForm.java | 7 +- .../sparrow/wallet/TransactionEntry.java | 122 ++++++++ .../wallet/TransactionHashIndexEntry.java | 44 +++ .../wallet/TransactionsController.java | 42 +++ .../sparrow/wallet/WalletForm.java | 17 ++ .../wallet/WalletTransactionsEntry.java | 103 +++++++ .../sparrow/wallet/addresses.css | 48 ---- .../sparrow/wallet/transactions.css | 5 + .../sparrow/wallet/transactions.fxml | 21 ++ .../sparrowwallet/sparrow/wallet/wallet.css | 50 ++++ 25 files changed, 924 insertions(+), 332 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/AmountCell.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/LabelCell.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/TransactionsTreeTable.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/event/WalletNodesChangedEvent.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/wallet/TransactionEntry.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/wallet/TransactionHashIndexEntry.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java create mode 100644 src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.css create mode 100644 src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml diff --git a/drongo b/drongo index 81378b28..18036268 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 81378b28b25d02dca8cdfc21a6b4fae0421d82b1 +Subproject commit 18036268e52af8ed8cd5a289f478b302fb415255 diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java b/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java index a454e629..ed16809f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java @@ -1,35 +1,18 @@ package com.sparrowwallet.sparrow.control; -import com.sparrowwallet.drongo.Utils; -import com.sparrowwallet.drongo.address.Address; -import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.wallet.WalletNode; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.ReceiveActionEvent; import com.sparrowwallet.sparrow.event.ReceiveToEvent; -import com.sparrowwallet.sparrow.event.ViewTransactionEvent; -import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.wallet.Entry; -import com.sparrowwallet.sparrow.wallet.HashIndexEntry; import com.sparrowwallet.sparrow.wallet.NodeEntry; import javafx.application.Platform; import javafx.beans.property.ReadOnlyObjectWrapper; -import javafx.event.Event; -import javafx.geometry.Pos; import javafx.scene.control.*; -import javafx.scene.control.cell.TextFieldTreeTableCell; -import javafx.scene.input.Clipboard; -import javafx.scene.input.ClipboardContent; import javafx.scene.input.MouseButton; -import javafx.scene.layout.Region; import javafx.scene.text.Font; -import javafx.util.converter.DefaultStringConverter; -import org.controlsfx.glyphfont.FontAwesome; -import org.controlsfx.glyphfont.Glyph; -import java.lang.reflect.Field; import java.util.List; -import java.util.Locale; import java.util.Optional; public class AddressTreeTable extends TreeTableView { @@ -37,17 +20,14 @@ public class AddressTreeTable extends TreeTableView { getStyleClass().add("address-treetable"); String address = rootEntry.getAddress().toString(); - RecursiveTreeItem rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren); - setRoot(rootItem); - - rootItem.setExpanded(true); + updateAll(rootEntry); setShowRoot(false); TreeTableColumn addressCol = new TreeTableColumn<>("Address / Outpoints"); addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { return new ReadOnlyObjectWrapper<>(param.getValue().getValue()); }); - addressCol.setCellFactory(p -> new DataCell()); + addressCol.setCellFactory(p -> new EntryCell()); addressCol.setSortable(false); getColumns().add(addressCol); @@ -96,6 +76,12 @@ public class AddressTreeTable extends TreeTableView { }); } + public void updateAll(NodeEntry rootEntry) { + RecursiveTreeItem rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren); + setRoot(rootItem); + rootItem.setExpanded(true); + } + public void updateHistory(List updatedNodes) { NodeEntry rootEntry = (NodeEntry)getRoot().getValue(); @@ -108,244 +94,4 @@ public class AddressTreeTable extends TreeTableView { } } } - - private static void applyRowStyles(TreeTableCell cell, Entry entry) { - cell.getStyleClass().remove("node-row"); - cell.getStyleClass().remove("hashindex-row"); - cell.getStyleClass().remove("spent"); - - if(entry != null) { - if(entry instanceof NodeEntry) { - cell.getStyleClass().add("node-row"); - } else if(entry instanceof HashIndexEntry) { - cell.getStyleClass().add("hashindex-row"); - HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; - if(hashIndexEntry.isSpent()) { - cell.getStyleClass().add("spent"); - } - } - } - } - - private static class DataCell extends TreeTableCell { - public DataCell() { - super(); - setAlignment(Pos.CENTER_LEFT); - setContentDisplay(ContentDisplay.RIGHT); - getStyleClass().add("data-cell"); - } - - @Override - protected void updateItem(Entry entry, boolean empty) { - super.updateItem(entry, empty); - - applyRowStyles(this, entry); - getStyleClass().remove("address-cell"); - - if(empty) { - setText(null); - setGraphic(null); - } else { - if(entry instanceof NodeEntry) { - NodeEntry nodeEntry = (NodeEntry)entry; - Address address = nodeEntry.getAddress(); - setText(address.toString()); - setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor())); - Tooltip tooltip = new Tooltip(); - tooltip.setText(nodeEntry.getNode().getDerivationPath()); - setTooltip(tooltip); - getStyleClass().add("address-cell"); - - Button receiveButton = new Button(""); - Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN); - receiveGlyph.setFontSize(12); - receiveButton.setGraphic(receiveGlyph); - receiveButton.setOnAction(event -> { - EventManager.get().post(new ReceiveActionEvent(nodeEntry)); - Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry))); - }); - setGraphic(receiveButton); - } else if(entry instanceof HashIndexEntry) { - HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; - setText(hashIndexEntry.getDescription()); - setContextMenu(new HashIndexEntryContextMenu(hashIndexEntry)); - Tooltip tooltip = new Tooltip(); - tooltip.setText(hashIndexEntry.getHashIndex().toString()); - setTooltip(tooltip); - - Button viewTransactionButton = new Button(""); - Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH); - searchGlyph.setFontSize(12); - viewTransactionButton.setGraphic(searchGlyph); - viewTransactionButton.setOnAction(event -> { - EventManager.get().post(new ViewTransactionEvent(hashIndexEntry.getBlockTransaction(), hashIndexEntry)); - }); - setGraphic(viewTransactionButton); - } - } - } - } - - private static class AddressContextMenu extends ContextMenu { - public AddressContextMenu(Address address, String outputDescriptor) { - MenuItem copyAddress = new MenuItem("Copy Address"); - copyAddress.setOnAction(AE -> { - hide(); - ClipboardContent content = new ClipboardContent(); - content.putString(address.toString()); - Clipboard.getSystemClipboard().setContent(content); - }); - - MenuItem copyHex = new MenuItem("Copy Script Output Bytes"); - copyHex.setOnAction(AE -> { - hide(); - ClipboardContent content = new ClipboardContent(); - content.putString(Utils.bytesToHex(address.getOutputScriptData())); - Clipboard.getSystemClipboard().setContent(content); - }); - - MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor"); - copyOutputDescriptor.setOnAction(AE -> { - hide(); - ClipboardContent content = new ClipboardContent(); - content.putString(outputDescriptor); - Clipboard.getSystemClipboard().setContent(content); - }); - - getItems().addAll(copyAddress, copyHex, copyOutputDescriptor); - } - } - - private static class HashIndexEntryContextMenu extends ContextMenu { - public HashIndexEntryContextMenu(HashIndexEntry hashIndexEntry) { - String label = "Copy " + (hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) ? "Transaction Output" : "Transaction Input"); - MenuItem copyHashIndex = new MenuItem(label); - copyHashIndex.setOnAction(AE -> { - hide(); - ClipboardContent content = new ClipboardContent(); - content.putString(hashIndexEntry.getHashIndex().toString()); - Clipboard.getSystemClipboard().setContent(content); - }); - - getItems().add(copyHashIndex); - } - } - - private static class LabelCell extends TextFieldTreeTableCell { - public LabelCell() { - super(new DefaultStringConverter()); - getStyleClass().add("label-cell"); - } - - @Override - public void updateItem(String label, boolean empty) { - super.updateItem(label, empty); - - if(empty) { - setText(null); - setGraphic(null); - } else { - applyRowStyles(this, getTreeTableView().getTreeItem(getIndex()).getValue()); - - setText(label); - setContextMenu(new LabelContextMenu(label)); - } - } - - @Override - public void commitEdit(String label) { - // This block is necessary to support commit on losing focus, because - // the baked-in mechanism sets our editing state to false before we can - // intercept the loss of focus. The default commitEdit(...) method - // simply bails if we are not editing... - if (!isEditing() && !label.equals(getItem())) { - TreeTableView treeTable = getTreeTableView(); - if(treeTable != null) { - TreeTableColumn column = getTableColumn(); - TreeTableColumn.CellEditEvent event = new TreeTableColumn.CellEditEvent<>( - treeTable, new TreeTablePosition<>(treeTable, getIndex(), column), - TreeTableColumn.editCommitEvent(), label - ); - Event.fireEvent(column, event); - } - } - - super.commitEdit(label); - } - - @Override - public void startEdit() { - super.startEdit(); - - try { - Field f = getClass().getSuperclass().getDeclaredField("textField"); - f.setAccessible(true); - TextField textField = (TextField)f.get(this); - textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> { - if (!isNowFocused) { - commitEdit(getConverter().fromString(textField.getText())); - setText(getConverter().fromString(textField.getText())); - } - }); - } catch (Exception e) { - e.printStackTrace(); - } - } - } - - private static class LabelContextMenu extends ContextMenu { - public LabelContextMenu(String label) { - MenuItem copyLabel = new MenuItem("Copy Label"); - copyLabel.setOnAction(AE -> { - hide(); - ClipboardContent content = new ClipboardContent(); - content.putString(label); - Clipboard.getSystemClipboard().setContent(content); - }); - - getItems().add(copyLabel); - } - } - - private static class AmountCell extends TreeTableCell { - public AmountCell() { - super(); - getStyleClass().add("amount-cell"); - setContentDisplay(ContentDisplay.RIGHT); - } - - @Override - protected void updateItem(Long amount, boolean empty) { - super.updateItem(amount, empty); - - if(empty || amount == null) { - setText(null); - setGraphic(null); - } else { - applyRowStyles(this, getTreeTableView().getTreeItem(getIndex()).getValue()); - - String satsValue = String.format(Locale.ENGLISH, "%,d", amount); - String btcValue = CoinLabel.getBTCFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC"; - - Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue(); - if(entry instanceof HashIndexEntry) { - Region node = new Region(); - node.setPrefWidth(10); - setGraphic(node); - - if(((HashIndexEntry) entry).getType() == HashIndexEntry.Type.INPUT) { - satsValue = "-" + satsValue; - } - } else { - setGraphic(null); - } - - Tooltip tooltip = new Tooltip(); - tooltip.setText(btcValue); - - setText(satsValue); - setTooltip(tooltip); - } - } - } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AmountCell.java b/src/main/java/com/sparrowwallet/sparrow/control/AmountCell.java new file mode 100644 index 00000000..907af83f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/AmountCell.java @@ -0,0 +1,53 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.HashIndexEntry; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Tooltip; +import javafx.scene.control.TreeTableCell; +import javafx.scene.layout.Region; + +import java.util.Locale; + +class AmountCell extends TreeTableCell { + public AmountCell() { + super(); + getStyleClass().add("amount-cell"); + setContentDisplay(ContentDisplay.RIGHT); + } + + @Override + protected void updateItem(Long amount, boolean empty) { + super.updateItem(amount, empty); + + if(empty || amount == null) { + setText(null); + setGraphic(null); + } else { + EntryCell.applyRowStyles(this, getTreeTableView().getTreeItem(getIndex()).getValue()); + + String satsValue = String.format(Locale.ENGLISH, "%,d", amount); + String btcValue = CoinLabel.getBTCFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC"; + + Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue(); + if(entry instanceof HashIndexEntry) { + Region node = new Region(); + node.setPrefWidth(10); + setGraphic(node); + + if(((HashIndexEntry) entry).getType() == HashIndexEntry.Type.INPUT) { + satsValue = "-" + satsValue; + } + } else { + setGraphic(null); + } + + Tooltip tooltip = new Tooltip(); + tooltip.setText(btcValue); + + setText(satsValue); + setTooltip(tooltip); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java new file mode 100644 index 00000000..48402988 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -0,0 +1,199 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.ReceiveActionEvent; +import com.sparrowwallet.sparrow.event.ReceiveToEvent; +import com.sparrowwallet.sparrow.event.ViewTransactionEvent; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.transaction.TransactionView; +import com.sparrowwallet.sparrow.wallet.*; +import javafx.application.Platform; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import org.controlsfx.glyphfont.FontAwesome; +import org.controlsfx.glyphfont.Glyph; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; + +class EntryCell extends TreeTableCell { + private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + + public EntryCell() { + super(); + setAlignment(Pos.CENTER_LEFT); + setContentDisplay(ContentDisplay.RIGHT); + getStyleClass().add("entry-cell"); + } + + @Override + protected void updateItem(Entry entry, boolean empty) { + super.updateItem(entry, empty); + + applyRowStyles(this, entry); + + if(empty) { + setText(null); + setGraphic(null); + } else { + if(entry instanceof TransactionEntry) { + TransactionEntry transactionEntry = (TransactionEntry)entry; + String date = DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate()); + setText(date); + setContextMenu(new TransactionContextMenu(date, transactionEntry.getBlockTransaction())); + Tooltip tooltip = new Tooltip(); + tooltip.setText(transactionEntry.getBlockTransaction().getHash().toString()); + setTooltip(tooltip); + + Button viewTransactionButton = new Button(""); + Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH); + searchGlyph.setFontSize(12); + viewTransactionButton.setGraphic(searchGlyph); + viewTransactionButton.setOnAction(event -> { + EventManager.get().post(new ViewTransactionEvent(transactionEntry.getBlockTransaction())); + }); + setGraphic(viewTransactionButton); + } else if(entry instanceof NodeEntry) { + NodeEntry nodeEntry = (NodeEntry)entry; + Address address = nodeEntry.getAddress(); + setText(address.toString()); + setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor())); + Tooltip tooltip = new Tooltip(); + tooltip.setText(nodeEntry.getNode().getDerivationPath()); + setTooltip(tooltip); + getStyleClass().add("address-cell"); + + Button receiveButton = new Button(""); + Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN); + receiveGlyph.setFontSize(12); + receiveButton.setGraphic(receiveGlyph); + receiveButton.setOnAction(event -> { + EventManager.get().post(new ReceiveActionEvent(nodeEntry)); + Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry))); + }); + setGraphic(receiveButton); + } else if(entry instanceof HashIndexEntry) { + HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; + setText(hashIndexEntry.getDescription()); + setContextMenu(new HashIndexEntryContextMenu(hashIndexEntry)); + Tooltip tooltip = new Tooltip(); + tooltip.setText(hashIndexEntry.getHashIndex().toString()); + setTooltip(tooltip); + + Button viewTransactionButton = new Button(""); + Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH); + searchGlyph.setFontSize(12); + viewTransactionButton.setGraphic(searchGlyph); + viewTransactionButton.setOnAction(event -> { + EventManager.get().post(new ViewTransactionEvent(hashIndexEntry.getBlockTransaction(), hashIndexEntry)); + }); + setGraphic(viewTransactionButton); + } + } + } + + private static class TransactionContextMenu extends ContextMenu { + public TransactionContextMenu(String date, BlockTransaction blockTransaction) { + MenuItem copyDate = new MenuItem("Copy Date"); + copyDate.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(date); + Clipboard.getSystemClipboard().setContent(content); + }); + + MenuItem copyTxid = new MenuItem("Copy Transaction ID"); + copyTxid.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(blockTransaction.getHashAsString()); + Clipboard.getSystemClipboard().setContent(content); + }); + + MenuItem copyHeight = new MenuItem("Copy Block Height"); + copyTxid.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(Integer.toString(blockTransaction.getHeight())); + Clipboard.getSystemClipboard().setContent(content); + }); + + getItems().addAll(copyDate, copyTxid, copyHeight); + } + } + + private static class AddressContextMenu extends ContextMenu { + public AddressContextMenu(Address address, String outputDescriptor) { + MenuItem copyAddress = new MenuItem("Copy Address"); + copyAddress.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(address.toString()); + Clipboard.getSystemClipboard().setContent(content); + }); + + MenuItem copyHex = new MenuItem("Copy Script Output Bytes"); + copyHex.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(Utils.bytesToHex(address.getOutputScriptData())); + Clipboard.getSystemClipboard().setContent(content); + }); + + MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor"); + copyOutputDescriptor.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(outputDescriptor); + Clipboard.getSystemClipboard().setContent(content); + }); + + getItems().addAll(copyAddress, copyHex, copyOutputDescriptor); + } + } + + private static class HashIndexEntryContextMenu extends ContextMenu { + public HashIndexEntryContextMenu(HashIndexEntry hashIndexEntry) { + String label = "Copy " + (hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) ? "Transaction Output" : "Transaction Input"); + MenuItem copyHashIndex = new MenuItem(label); + copyHashIndex.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(hashIndexEntry.getHashIndex().toString()); + Clipboard.getSystemClipboard().setContent(content); + }); + + getItems().add(copyHashIndex); + } + } + + public static void applyRowStyles(TreeTableCell cell, Entry entry) { + cell.getStyleClass().remove("transaction-row"); + cell.getStyleClass().remove("node-row"); + cell.getStyleClass().remove("address-cell"); + cell.getStyleClass().remove("hashindex-row"); + cell.getStyleClass().remove("spent"); + + if(entry != null) { + if(entry instanceof TransactionEntry) { + cell.getStyleClass().add("transaction-row"); + } else if(entry instanceof NodeEntry) { + cell.getStyleClass().add("node-row"); + } else if(entry instanceof HashIndexEntry) { + cell.getStyleClass().add("hashindex-row"); + HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; + if(hashIndexEntry.isSpent()) { + cell.getStyleClass().add("spent"); + } + } + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/LabelCell.java b/src/main/java/com/sparrowwallet/sparrow/control/LabelCell.java new file mode 100644 index 00000000..ce7ad824 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/LabelCell.java @@ -0,0 +1,87 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.sparrow.wallet.Entry; +import javafx.event.Event; +import javafx.scene.control.*; +import javafx.scene.control.cell.TextFieldTreeTableCell; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.util.converter.DefaultStringConverter; + +import java.lang.reflect.Field; + +class LabelCell extends TextFieldTreeTableCell { + public LabelCell() { + super(new DefaultStringConverter()); + getStyleClass().add("label-cell"); + } + + @Override + public void updateItem(String label, boolean empty) { + super.updateItem(label, empty); + + if(empty) { + setText(null); + setGraphic(null); + } else { + EntryCell.applyRowStyles(this, getTreeTableView().getTreeItem(getIndex()).getValue()); + + setText(label); + setContextMenu(new LabelContextMenu(label)); + } + } + + @Override + public void commitEdit(String label) { + // This block is necessary to support commit on losing focus, because + // the baked-in mechanism sets our editing state to false before we can + // intercept the loss of focus. The default commitEdit(...) method + // simply bails if we are not editing... + if (!isEditing() && !label.equals(getItem())) { + TreeTableView treeTable = getTreeTableView(); + if(treeTable != null) { + TreeTableColumn column = getTableColumn(); + TreeTableColumn.CellEditEvent event = new TreeTableColumn.CellEditEvent<>( + treeTable, new TreeTablePosition<>(treeTable, getIndex(), column), + TreeTableColumn.editCommitEvent(), label + ); + Event.fireEvent(column, event); + } + } + + super.commitEdit(label); + } + + @Override + public void startEdit() { + super.startEdit(); + + try { + Field f = getClass().getSuperclass().getDeclaredField("textField"); + f.setAccessible(true); + TextField textField = (TextField)f.get(this); + textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> { + if (!isNowFocused) { + commitEdit(getConverter().fromString(textField.getText())); + setText(getConverter().fromString(textField.getText())); + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private static class LabelContextMenu extends ContextMenu { + public LabelContextMenu(String label) { + MenuItem copyLabel = new MenuItem("Copy Label"); + copyLabel.setOnAction(AE -> { + hide(); + ClipboardContent content = new ClipboardContent(); + content.putString(label); + Clipboard.getSystemClipboard().setContent(content); + }); + + getItems().add(copyLabel); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionsTreeTable.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionsTreeTable.java new file mode 100644 index 00000000..e9c877d5 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionsTreeTable.java @@ -0,0 +1,68 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.TransactionEntry; +import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.scene.control.TreeTableColumn; +import javafx.scene.control.TreeTableView; + +import java.util.List; + +public class TransactionsTreeTable extends TreeTableView { + public void initialize(WalletTransactionsEntry rootEntry) { + getStyleClass().add("transactions-treetable"); + + updateAll(rootEntry); + setShowRoot(false); + + TreeTableColumn dateCol = new TreeTableColumn<>("Date"); + dateCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { + return new ReadOnlyObjectWrapper<>(param.getValue().getValue()); + }); + dateCol.setCellFactory(p -> new EntryCell()); + dateCol.setSortable(true); + getColumns().add(dateCol); + + TreeTableColumn labelCol = new TreeTableColumn<>("Label"); + labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { + return param.getValue().getValue().labelProperty(); + }); + labelCol.setCellFactory(p -> new LabelCell()); + labelCol.setSortable(true); + getColumns().add(labelCol); + + TreeTableColumn amountCol = new TreeTableColumn<>("Value"); + amountCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { + return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue()); + }); + amountCol.setCellFactory(p -> new AmountCell()); + amountCol.setSortable(true); + getColumns().add(amountCol); + + TreeTableColumn balanceCol = new TreeTableColumn<>("Balance"); + balanceCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { + return new ReadOnlyObjectWrapper<>(param.getValue().getValue() instanceof TransactionEntry ? ((TransactionEntry)param.getValue().getValue()).getBalance() : null); + }); + balanceCol.setCellFactory(p -> new AmountCell()); + balanceCol.setSortable(true); + getColumns().add(balanceCol); + + setEditable(true); + setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY); + dateCol.setSortType(TreeTableColumn.SortType.DESCENDING); + getSortOrder().add(dateCol); + } + + public void updateAll(WalletTransactionsEntry rootEntry) { + RecursiveTreeItem rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren); + setRoot(rootItem); + rootItem.setExpanded(true); + } + + public void updateHistory(List updatedNodes) { + WalletTransactionsEntry rootEntry = (WalletTransactionsEntry)getRoot().getValue(); + rootEntry.updateTransactions(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java index 16d75938..bf1c3a63 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletBlockHeightChangedEvent.java @@ -2,8 +2,12 @@ package com.sparrowwallet.sparrow.event; import com.sparrowwallet.drongo.wallet.Wallet; +/** + * This event is posted if the wallet block height has changed. + * Note that it is not posted if the wallet history has also changed - this event is used mainly to ensure the new block height is saved + */ public class WalletBlockHeightChangedEvent extends WalletChangedEvent { - private Integer blockHeight; + private final Integer blockHeight; public WalletBlockHeightChangedEvent(Wallet wallet, Integer blockHeight) { super(wallet); diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java index 06da2a83..df10bc3b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java @@ -2,6 +2,9 @@ package com.sparrowwallet.sparrow.event; import com.sparrowwallet.drongo.wallet.Wallet; +/** + * The base class for all wallet events that should also trigger saving of the wallet + */ public class WalletChangedEvent { private final Wallet wallet; diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java index 3a149d5a..c7824142 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java @@ -7,6 +7,10 @@ import com.sparrowwallet.drongo.wallet.WalletNode; import java.util.List; import java.util.stream.Collectors; +/** + * This is posted by WalletForm once the history of the wallet has been refreshed, and new transactions detected + * Extends WalletChangedEvent so also saves the wallet. + */ public class WalletHistoryChangedEvent extends WalletChangedEvent { private final List historyChangedNodes; diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodesChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodesChangedEvent.java new file mode 100644 index 00000000..74e2d479 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodesChangedEvent.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.wallet.Wallet; + +/** + * This event is posted by WalletForm once it has received a WalletSettingsChangedEvent and cleared it's entry caches + * It does not extend WalletChangedEvent for the same reason WalletSettingsChangedEvent does not - it does not want to trigger a wallet save. + */ +public class WalletNodesChangedEvent { + private final Wallet wallet; + + public WalletNodesChangedEvent(Wallet wallet) { + this.wallet = wallet; + } + + public Wallet getWallet() { + return wallet; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletSettingsChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletSettingsChangedEvent.java index af841811..aae46e64 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletSettingsChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletSettingsChangedEvent.java @@ -4,6 +4,12 @@ import com.sparrowwallet.drongo.wallet.Wallet; import java.io.File; +/** + * This event is posted when a wallet's settings are changed (keystores, policy, script type). + * This event marks a fundamental change that is used to update application level UI, clear node entry caches and similar. It should only be subscribed to by application-level classes. + * It does not extend WalletChangedEvent since that is used to save the wallet, and the wallet is saved directly in SettingsController before this event is posted. + * Note that all wallet detail controllers that share a WalletForm, that class posts WalletNodesChangedEvent once it has cleared it's entry caches. + */ public class WalletSettingsChangedEvent { private final Wallet wallet; private final File walletFile; diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java index b4588da8..52fe0631 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java @@ -2,11 +2,11 @@ package com.sparrowwallet.sparrow.wallet; import com.google.common.eventbus.Subscribe; import com.sparrowwallet.drongo.KeyPurpose; -import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.WalletNode; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.AddressTreeTable; import com.sparrowwallet.sparrow.event.WalletHistoryChangedEvent; +import com.sparrowwallet.sparrow.event.WalletNodesChangedEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; @@ -28,22 +28,30 @@ public class AddressesController extends WalletFormController implements Initial @Override public void initializeView() { - Wallet wallet = walletForm.getWallet(); - receiveTable.initialize(getWalletForm().getNodeEntry(KeyPurpose.RECEIVE)); changeTable.initialize(getWalletForm().getNodeEntry(KeyPurpose.CHANGE)); } @Subscribe - public void walletHistoryChanged(WalletHistoryChangedEvent event) { - List receiveNodes = event.getReceiveNodes(); - if(!receiveNodes.isEmpty()) { - receiveTable.updateHistory(receiveNodes); + public void walletNodesChanged(WalletNodesChangedEvent event) { + if(event.getWallet().equals(walletForm.getWallet())) { + receiveTable.updateAll(getWalletForm().getNodeEntry(KeyPurpose.RECEIVE)); + changeTable.updateAll(getWalletForm().getNodeEntry(KeyPurpose.CHANGE)); } + } - List changeNodes = event.getChangeNodes(); - if(!changeNodes.isEmpty()) { - changeTable.updateHistory(changeNodes); + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + if(event.getWallet().equals(walletForm.getWallet())) { + List receiveNodes = event.getReceiveNodes(); + if(!receiveNodes.isEmpty()) { + receiveTable.updateHistory(receiveNodes); + } + + List changeNodes = event.getChangeNodes(); + if(!changeNodes.isEmpty()) { + changeTable.updateHistory(changeNodes); + } } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/HashIndexEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/HashIndexEntry.java index ff00f18f..147ca59e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/HashIndexEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/HashIndexEntry.java @@ -1,5 +1,6 @@ package com.sparrowwallet.sparrow.wallet; +import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.wallet.BlockTransaction; import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; import com.sparrowwallet.drongo.wallet.Wallet; @@ -9,17 +10,20 @@ import com.sparrowwallet.sparrow.event.WalletChangedEvent; import java.util.Collections; import java.util.List; +import java.util.Objects; -public class HashIndexEntry extends Entry { +public class HashIndexEntry extends Entry implements Comparable { private final Wallet wallet; private final BlockTransactionHashIndex hashIndex; private final Type type; + private final KeyPurpose keyPurpose; - public HashIndexEntry(Wallet wallet, BlockTransactionHashIndex hashIndex, Type type) { - super(hashIndex.getLabel(), hashIndex.getSpentBy() != null ? List.of(new HashIndexEntry(wallet, hashIndex.getSpentBy(), Type.INPUT)) : Collections.emptyList()); + public HashIndexEntry(Wallet wallet, BlockTransactionHashIndex hashIndex, Type type, KeyPurpose keyPurpose) { + super(hashIndex.getLabel(), hashIndex.getSpentBy() != null ? List.of(new HashIndexEntry(wallet, hashIndex.getSpentBy(), Type.INPUT, keyPurpose)) : Collections.emptyList()); this.wallet = wallet; this.hashIndex = hashIndex; this.type = type; + this.keyPurpose = keyPurpose; labelProperty().addListener((observable, oldValue, newValue) -> { hashIndex.setLabel(newValue); @@ -39,13 +43,18 @@ public class HashIndexEntry extends Entry { return type; } + public KeyPurpose getKeyPurpose() { + return keyPurpose; + } + public BlockTransaction getBlockTransaction() { return wallet.getTransactions().get(hashIndex.getHash()); } public String getDescription() { - return (type.equals(Type.INPUT) ? "Spent by " : "Received from ") + - getHashIndex().getHash().toString().substring(0, 8) + "...:" + getHashIndex().getIndex() + + return (type.equals(Type.INPUT) ? "Spent by input " : "Received from output ") + + getHashIndex().getHash().toString().substring(0, 8) + "...:" + + getHashIndex().getIndex() + " on " + DateLabel.getShortDateFormat(getHashIndex().getDate()); } @@ -61,4 +70,33 @@ public class HashIndexEntry extends Entry { public enum Type { INPUT, OUTPUT } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + HashIndexEntry that = (HashIndexEntry) o; + return wallet.equals(that.wallet) && + hashIndex.equals(that.hashIndex) && + type == that.type && + keyPurpose == that.keyPurpose; + } + + @Override + public int hashCode() { + return Objects.hash(wallet, hashIndex, type, keyPurpose); + } + + @Override + public int compareTo(HashIndexEntry o) { + if(!getType().equals(o.getType())) { + return o.getType().ordinal() - getType().ordinal(); + } + + if(getHashIndex().getHeight() != o.getHashIndex().getHeight()) { + return o.getHashIndex().getHeight() - getHashIndex().getHeight(); + } + + return (int)o.getHashIndex().getIndex() - (int)getHashIndex().getIndex(); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/NodeEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/NodeEntry.java index 6c88c5d4..3f8553e1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/NodeEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/NodeEntry.java @@ -17,7 +17,7 @@ public class NodeEntry extends Entry { super(node.getLabel(), !node.getChildren().isEmpty() ? node.getChildren().stream().map(childNode -> new NodeEntry(wallet, childNode)).collect(Collectors.toList()) : - node.getTransactionOutputs().stream().map(txo -> new HashIndexEntry(wallet, txo, HashIndexEntry.Type.OUTPUT)).collect(Collectors.toList())); + node.getTransactionOutputs().stream().map(txo -> new HashIndexEntry(wallet, txo, HashIndexEntry.Type.OUTPUT, node.getKeyPurpose())).collect(Collectors.toList())); this.wallet = wallet; this.node = node; diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java index 6518f55f..d4708590 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java @@ -276,7 +276,6 @@ public class SettingsController extends WalletFormController implements Initiali try { walletForm.getStorage().setEncryptionPubKey(Storage.NO_PASSWORD_KEY); walletForm.saveAndRefresh(); - EventManager.get().post(new WalletSettingsChangedEvent(walletForm.getWallet(), walletForm.getWalletFile())); } catch (IOException e) { AppController.showErrorDialog("Error saving wallet", e.getMessage()); revert.setDisable(false); @@ -304,7 +303,6 @@ public class SettingsController extends WalletFormController implements Initiali walletForm.getStorage().setEncryptionPubKey(encryptionPubKey); walletForm.saveAndRefresh(); - EventManager.get().post(new WalletSettingsChangedEvent(walletForm.getWallet(), walletForm.getWalletFile())); } catch (Exception e) { AppController.showErrorDialog("Error saving wallet", e.getMessage()); revert.setDisable(false); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java index 79471a01..b97d29ea 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java @@ -1,6 +1,8 @@ package com.sparrowwallet.sparrow.wallet; import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent; import com.sparrowwallet.sparrow.io.Storage; import java.io.IOException; @@ -25,10 +27,11 @@ public class SettingsWalletForm extends WalletForm { @Override public void saveAndRefresh() throws IOException { - //TODO: Detect trivial changes and don't clear history - walletCopy.clearHistory(); + //TODO: Detect trivial changes and don't clear everything + walletCopy.clearNodes(); wallet = walletCopy.copy(); save(); + EventManager.get().post(new WalletSettingsChangedEvent(wallet, getWalletFile())); refreshHistory(wallet.getStoredBlockHeight()); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionEntry.java new file mode 100644 index 00000000..368d1fac --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionEntry.java @@ -0,0 +1,122 @@ +package com.sparrowwallet.sparrow.wallet; + +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.TransactionInput; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.WalletChangedEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.stream.Collectors; + +public class TransactionEntry extends Entry implements Comparable { + private static final int BLOCKS_TO_CONFIRM = 6; + + private final Wallet wallet; + private final BlockTransaction blockTransaction; + private WalletTransactionsEntry parent; + + public TransactionEntry(Wallet wallet, BlockTransaction blockTransaction, Map inputs, Map outputs) { + super(blockTransaction.getLabel(), createChildEntries(wallet, inputs, outputs)); + this.wallet = wallet; + this.blockTransaction = blockTransaction; + + labelProperty().addListener((observable, oldValue, newValue) -> { + blockTransaction.setLabel(newValue); + EventManager.get().post(new WalletChangedEvent(wallet)); + }); + } + + public Wallet getWallet() { + return wallet; + } + + void setParent(WalletTransactionsEntry walletTransactionsEntry) { + this.parent = walletTransactionsEntry; + } + + public BlockTransaction getBlockTransaction() { + return blockTransaction; + } + + @Override + public Long getValue() { + long value = 0L; + for(Entry entry : getChildren()) { + HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; + if(hashIndexEntry.getType().equals(HashIndexEntry.Type.INPUT)) { + value -= hashIndexEntry.getValue(); + } else { + value += hashIndexEntry.getValue(); + } + } + + return value; + } + + public Long getBalance() { + return parent.getBalance(this); + } + + public boolean isConfirming() { + return getConfirmations() < BLOCKS_TO_CONFIRM; + } + + public int getConfirmations() { + if(blockTransaction.getHeight() == 0) { + return 0; + } + + return wallet.getStoredBlockHeight() - blockTransaction.getHeight() + 1; + } + + private static List createChildEntries(Wallet wallet, Map incoming, Map outgoing) { + List incomingOutputEntries = incoming.entrySet().stream().map(input -> new TransactionHashIndexEntry(wallet, input.getKey(), HashIndexEntry.Type.OUTPUT, input.getValue())).collect(Collectors.toList()); + List outgoingInputEntries = outgoing.entrySet().stream().map(output -> new TransactionHashIndexEntry(wallet, output.getKey(), HashIndexEntry.Type.INPUT, output.getValue())).collect(Collectors.toList()); + + List childEntries = new ArrayList<>(); + childEntries.addAll(incomingOutputEntries); + childEntries.addAll(outgoingInputEntries); + + childEntries.sort((o1, o2) -> { + TransactionHashIndexEntry entry1 = (TransactionHashIndexEntry) o1; + TransactionHashIndexEntry entry2 = (TransactionHashIndexEntry) o2; + + if (!entry1.getHashIndex().getHash().equals(entry2.getHashIndex().getHash())) { + return entry1.getHashIndex().getHash().compareTo(entry2.getHashIndex().getHash()); + } + + if (!entry1.getType().equals(entry2.getType())) { + return entry1.getType().ordinal() - entry2.getType().ordinal(); + } + + return (int) entry1.getHashIndex().getIndex() - (int) entry2.getHashIndex().getIndex(); + }); + + return childEntries; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TransactionEntry that = (TransactionEntry) o; + return wallet.equals(that.wallet) && + blockTransaction.equals(that.blockTransaction) && + parent.equals(that.parent); + } + + @Override + public int hashCode() { + return Objects.hash(wallet, blockTransaction, parent); + } + + @Override + public int compareTo(@NotNull TransactionEntry other) { + return blockTransaction.compareTo(other.blockTransaction); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionHashIndexEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionHashIndexEntry.java new file mode 100644 index 00000000..d91bc33f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionHashIndexEntry.java @@ -0,0 +1,44 @@ +package com.sparrowwallet.sparrow.wallet; + +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.TransactionInput; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +public class TransactionHashIndexEntry extends HashIndexEntry { + public TransactionHashIndexEntry(Wallet wallet, BlockTransactionHashIndex hashIndex, Type type, KeyPurpose keyPurpose) { + super(wallet, hashIndex, type, keyPurpose); + } + + @Override + public ObservableList getChildren() { + return FXCollections.emptyObservableList(); + } + + @Override + public String getDescription() { + if(getType().equals(Type.INPUT)) { + TransactionInput txInput = getBlockTransaction().getTransaction().getInputs().get((int)getHashIndex().getIndex()); + return "Spent " + txInput.getOutpoint().getHash().toString().substring(0, 8) + "...:" + txInput.getOutpoint().getIndex(); + } else { + return (getKeyPurpose().equals(KeyPurpose.RECEIVE) ? "Received to " : "Change to ") + getHashIndex().getHash().toString().substring(0, 8) + "...:" + getHashIndex().getIndex(); + } + } + + public BlockTransaction getSpentByTransaction() { + if(getHashIndex().getSpentBy() != null) { + return getWallet().getTransactions().get(getHashIndex().getSpentBy().getHash()); + } + + return null; + } + + @Override + public boolean isSpent() { + return false; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java new file mode 100644 index 00000000..716b413e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java @@ -0,0 +1,42 @@ +package com.sparrowwallet.sparrow.wallet; + +import com.google.common.eventbus.Subscribe; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.control.TransactionsTreeTable; +import com.sparrowwallet.sparrow.event.WalletHistoryChangedEvent; +import com.sparrowwallet.sparrow.event.WalletNodesChangedEvent; +import javafx.fxml.FXML; +import javafx.fxml.Initializable; + +import java.net.URL; +import java.util.ResourceBundle; + +public class TransactionsController extends WalletFormController implements Initializable { + + @FXML + private TransactionsTreeTable transactionsTable; + + @Override + public void initialize(URL location, ResourceBundle resources) { + EventManager.get().register(this); + } + + @Override + public void initializeView() { + transactionsTable.initialize(getWalletForm().getWalletTransactionsEntry()); + } + + @Subscribe + public void walletNodesChanged(WalletNodesChangedEvent event) { + if(event.getWallet().equals(walletForm.getWallet())) { + transactionsTable.updateAll(getWalletForm().getWalletTransactionsEntry()); + } + } + + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + if(event.getWallet().equals(walletForm.getWallet())) { + transactionsTable.updateHistory(event.getHistoryChangedNodes()); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java index a492387a..7fac10aa 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java @@ -19,6 +19,7 @@ public class WalletForm { private final Storage storage; protected Wallet wallet; + private WalletTransactionsEntry walletTransactionsEntry; private final List accountEntries = new ArrayList<>(); public WalletForm(Storage storage, Wallet currentWallet) { @@ -125,6 +126,14 @@ public class WalletForm { return freshEntry; } + public WalletTransactionsEntry getWalletTransactionsEntry() { + if(walletTransactionsEntry == null) { + walletTransactionsEntry = new WalletTransactionsEntry(wallet); + } + + return walletTransactionsEntry; + } + @Subscribe public void walletChanged(WalletChangedEvent event) { if(event.getWallet().equals(wallet)) { @@ -137,6 +146,14 @@ public class WalletForm { } } + @Subscribe + public void walletSettingsChanged(WalletSettingsChangedEvent event) { + walletTransactionsEntry = null; + accountEntries.clear(); + + EventManager.get().post(new WalletNodesChangedEvent(wallet)); + } + @Subscribe public void newBlock(NewBlockEvent event) { refreshHistory(event.getHeight()); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java new file mode 100644 index 00000000..616bae5f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java @@ -0,0 +1,103 @@ +package com.sparrowwallet.sparrow.wallet; + +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; + +import java.util.*; +import java.util.stream.Collectors; + +public class WalletTransactionsEntry extends Entry { + private final Wallet wallet; + + public WalletTransactionsEntry(Wallet wallet) { + super(wallet.getName(), getWalletTransactions(wallet).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList())); + this.wallet = wallet; + getChildren().forEach(entry -> ((TransactionEntry)entry).setParent(this)); + } + + @Override + public Long getValue() { + return getBalance(null); + } + + protected Long getBalance(TransactionEntry transactionEntry) { + long balance = 0L; + for(Entry entry : getChildren()) { + balance += entry.getValue(); + + if(entry == transactionEntry) { + return balance; + } + } + + return balance; + } + + public void updateTransactions() { + List current = getWalletTransactions(wallet).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList()); + List previous = new ArrayList<>(getChildren()); + for(Entry currentEntry : current) { + int index = previous.indexOf(currentEntry); + if (index > -1) { + getChildren().set(index, currentEntry); + } else { + getChildren().add(currentEntry); + } + } + + getChildren().sort(Comparator.comparing(TransactionEntry.class::cast)); + } + + private static Collection getWalletTransactions(Wallet wallet) { + Map walletTransactionMap = new TreeMap<>(); + + getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(KeyPurpose.RECEIVE)); + getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(KeyPurpose.CHANGE)); + + return walletTransactionMap.values(); + } + + private static void getWalletTransactions(Wallet wallet, Map walletTransactionMap, WalletNode purposeNode) { + KeyPurpose keyPurpose = purposeNode.getKeyPurpose(); + for(WalletNode addressNode : purposeNode.getChildren()) { + for(BlockTransactionHashIndex hashIndex : addressNode.getTransactionOutputs()) { + BlockTransaction inputTx = wallet.getTransactions().get(hashIndex.getHash()); + WalletTransaction inputWalletTx = walletTransactionMap.get(inputTx); + if(inputWalletTx == null) { + inputWalletTx = new WalletTransaction(wallet, inputTx); + walletTransactionMap.put(inputTx, inputWalletTx); + } + inputWalletTx.incoming.put(hashIndex, keyPurpose); + + if(hashIndex.getSpentBy() != null) { + BlockTransaction outputTx = wallet.getTransactions().get(hashIndex.getSpentBy().getHash()); + WalletTransaction outputWalletTx = walletTransactionMap.get(outputTx); + if(outputWalletTx == null) { + outputWalletTx = new WalletTransaction(wallet, outputTx); + walletTransactionMap.put(outputTx, outputWalletTx); + } + outputWalletTx.outgoing.put(hashIndex.getSpentBy(), keyPurpose); + } + } + } + } + + private static class WalletTransaction { + private final Wallet wallet; + private final BlockTransaction blockTransaction; + private final Map incoming = new TreeMap<>(); + private final Map outgoing = new TreeMap<>(); + + public WalletTransaction(Wallet wallet, BlockTransaction blockTransaction) { + this.wallet = wallet; + this.blockTransaction = blockTransaction; + } + + public TransactionEntry getTransactionEntry() { + return new TransactionEntry(wallet, blockTransaction, incoming, outgoing); + } + } +} diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/addresses.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/addresses.css index cb9351bc..8b137891 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/addresses.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/addresses.css @@ -1,49 +1 @@ -.address-treetable-label { - -fx-font-weight: bold; - -fx-font-size: 1.2em; - -fx-padding: 10 0 10 0; -} -.address-cell { - -fx-font-family: Courier; -} - -.hashindex-row { - -fx-text-fill: #696c77; -} - -.hashindex-row.spent { - -fx-text-fill: #a0a1a7; -} - -.tree-table-row-cell:selected .hashindex-row { - -fx-text-fill: white; -} - -.label-cell .text-field { - -fx-padding: 0; -} - -.amount-cell { - -fx-alignment: center-right; -} - -.amount-cell.spent .text { - -fx-strikethrough: true; -} - -.data-cell .button { - -fx-padding: 0; - -fx-pref-height: 18; - -fx-pref-width: 18; - -fx-border-width: 0; - -fx-background-color: -fx-background; -} - -.data-cell .button .label .text { - -fx-fill: -fx-background; -} - -.data-cell:hover .button .label .text { - -fx-fill: -fx-text-base-color; -} diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.css new file mode 100644 index 00000000..1f24160d --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.css @@ -0,0 +1,5 @@ +.transactions-treetable-label { + -fx-font-weight: bold; + -fx-font-size: 1.2em; + -fx-padding: 10 0 10 0; +} \ No newline at end of file diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml new file mode 100644 index 00000000..1fbf0e14 --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + +
+ +
+
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css index c1c159c4..2341879b 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css @@ -28,3 +28,53 @@ .wallet-pane { -fx-background-color: -fx-background; } + +.address-treetable-label { + -fx-font-weight: bold; + -fx-font-size: 1.2em; + -fx-padding: 10 0 10 0; +} + +.address-cell { + -fx-font-family: Courier; +} + +.hashindex-row { + -fx-text-fill: #696c77; +} + +.hashindex-row.spent { + -fx-text-fill: #a0a1a7; +} + +.tree-table-row-cell:selected .hashindex-row { + -fx-text-fill: white; +} + +.label-cell .text-field { + -fx-padding: 0; +} + +.amount-cell { + -fx-alignment: center-right; +} + +.amount-cell.spent .text { + -fx-strikethrough: true; +} + +.entry-cell .button { + -fx-padding: 0; + -fx-pref-height: 18; + -fx-pref-width: 18; + -fx-border-width: 0; + -fx-background-color: -fx-background; +} + +.entry-cell .button .label .text { + -fx-fill: -fx-background; +} + +.entry-cell:hover .button .label .text { + -fx-fill: -fx-text-base-color; +}