From ac438ec0236a137b51671b128408b52f0756949d Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 20 Oct 2020 10:56:08 +0200 Subject: [PATCH] send to multiple recipients --- drongo | 2 +- .../sparrow/control/TransactionDiagram.java | 26 +- .../sparrow/glyphfont/FontAwesome5.java | 1 + .../sparrow/wallet/PaymentController.java | 334 +++++++++++++++ .../sparrow/wallet/SendController.java | 386 +++++++++--------- .../com/sparrowwallet/sparrow/darktheme.css | 6 + .../com/sparrowwallet/sparrow/general.css | 4 + .../sparrowwallet/sparrow/wallet/payment.css | 0 .../sparrowwallet/sparrow/wallet/payment.fxml | 74 ++++ .../sparrowwallet/sparrow/wallet/receive.fxml | 2 +- .../com/sparrowwallet/sparrow/wallet/send.css | 24 +- .../sparrowwallet/sparrow/wallet/send.fxml | 44 +- .../sparrow/wallet/settings.fxml | 2 +- .../sparrow/wallet/transactions.fxml | 2 +- 14 files changed, 660 insertions(+), 247 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java create mode 100644 src/main/resources/com/sparrowwallet/sparrow/wallet/payment.css create mode 100644 src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml diff --git a/drongo b/drongo index 8b07336d..c4f5218f 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 8b07336d71f32094acb8eb8c162ebd8621ffc4aa +Subproject commit c4f5218f29ef58e9ce265373206a093157610fdb diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index c7d1c63e..ddab0887 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -1,5 +1,6 @@ package com.sparrowwallet.sparrow.control; +import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; import com.sparrowwallet.drongo.wallet.Payment; @@ -24,6 +25,7 @@ import org.controlsfx.glyphfont.FontAwesome; import org.controlsfx.glyphfont.Glyph; import java.util.*; +import java.util.stream.Collectors; public class TransactionDiagram extends GridPane { private static final int MAX_UTXOS = 8; @@ -76,7 +78,7 @@ public class TransactionDiagram extends GridPane { Map utxos = new LinkedHashMap<>(); List additional = new ArrayList<>(); for(BlockTransactionHashIndex reference : selectedUtxos.keySet()) { - if (utxos.size() < MAX_UTXOS) { + if(utxos.size() < MAX_UTXOS - 1) { utxos.put(reference, selectedUtxos.get(reference)); } else { additional.add(reference); @@ -169,7 +171,8 @@ public class TransactionDiagram extends GridPane { Tooltip tooltip = new Tooltip(); if(walletNode != null) { - tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath() + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode)); + KeyDerivation fullDerivation = walletTx.getWallet().getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation()); + tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + fullDerivation.getDerivationPath() + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode)); tooltip.getStyleClass().add("input-label"); if(input.getLabel() == null || input.getLabel().isEmpty()) { @@ -252,7 +255,7 @@ public class TransactionDiagram extends GridPane { List displayedPayments = new ArrayList<>(); List additional = new ArrayList<>(); for(Payment payment : payments) { - if(displayedPayments.size() < MAX_PAYMENTS) { + if(displayedPayments.size() < MAX_PAYMENTS - 1) { displayedPayments.add(payment); } else { additional.add(payment); @@ -316,8 +319,9 @@ public class TransactionDiagram extends GridPane { boolean isConsolidation = walletTx.isConsolidationSend(payment); String recipientDesc = payment instanceof AdditionalPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "..."; Label recipientLabel = new Label(recipientDesc, isConsolidation ? getConsolidationGlyph() : getPaymentGlyph()); - recipientLabel.getStyleClass().addAll("output-label", "recipient-label"); - Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to\n" + payment.getAddress().toString()); + recipientLabel.getStyleClass().add("output-label"); + recipientLabel.getStyleClass().add(payment instanceof AdditionalPayment ? "additional-label" : "recipient-label"); + Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString())); recipientTooltip.getStyleClass().add("recipient-label"); recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); recipientLabel.setTooltip(recipientTooltip); @@ -329,7 +333,8 @@ public class TransactionDiagram extends GridPane { String changeDesc = walletTx.getChangeAddress().toString().substring(0, 8) + "..."; Label changeLabel = new Label(changeDesc, getChangeGlyph()); changeLabel.getStyleClass().addAll("output-label", "change-label"); - Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath() + "\n" + walletTx.getChangeAddress().toString()); + KeyDerivation fullDerivation = walletTx.getWallet().getKeystores().get(0).getKeyDerivation().extend(walletTx.getChangeNode().getDerivation()); + Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + fullDerivation.getDerivationPath() + "\n" + walletTx.getChangeAddress().toString()); changeTooltip.getStyleClass().add("change-label"); changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); changeLabel.setTooltip(changeTooltip); @@ -360,6 +365,8 @@ public class TransactionDiagram extends GridPane { String txDesc = "Transaction"; Label txLabel = new Label(txDesc); Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + walletTx.getTransaction().getVirtualSize() + " vBytes"); + tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); + tooltip.getStyleClass().add("transaction-tooltip"); txLabel.setTooltip(tooltip); txPane.getChildren().add(txLabel); txPane.getChildren().add(createSpacer()); @@ -441,8 +448,15 @@ public class TransactionDiagram extends GridPane { } private static class AdditionalPayment extends Payment { + private final List additionalPayments; + public AdditionalPayment(List additionalPayments) { super(null, additionalPayments.size() + " more...", additionalPayments.stream().map(Payment::getAmount).mapToLong(v -> v).sum(), false); + this.additionalPayments = additionalPayments; + } + + public String toString() { + return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n")); } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index 115bf7c4..f8682b68 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -35,6 +35,7 @@ public class FontAwesome5 extends GlyphFont { LOCK('\uf023'), LOCK_OPEN('\uf3c1'), PEN_FANCY('\uf5ac'), + PLUS('\uf067'), QRCODE('\uf029'), QUESTION_CIRCLE('\uf059'), REPLY_ALL('\uf122'), diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java new file mode 100644 index 00000000..3a5791f5 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -0,0 +1,334 @@ +package com.sparrowwallet.sparrow.wallet; + +import com.google.common.eventbus.Subscribe; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.address.P2PKHAddress; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.wallet.MaxUtxoSelector; +import com.sparrowwallet.drongo.wallet.Payment; +import com.sparrowwallet.drongo.wallet.UtxoSelector; +import com.sparrowwallet.sparrow.AppController; +import com.sparrowwallet.sparrow.CurrencyRate; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.control.CoinTextFormatter; +import com.sparrowwallet.sparrow.control.CopyableTextField; +import com.sparrowwallet.sparrow.control.FiatLabel; +import com.sparrowwallet.sparrow.control.QRScanDialog; +import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent; +import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent; +import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.ExchangeSource; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.collections.ListChangeListener; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.fxml.Initializable; +import javafx.scene.control.*; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.Validator; + +import java.net.URL; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.*; + +public class PaymentController extends WalletFormController implements Initializable { + private SendController sendController; + + private ValidationSupport validationSupport; + + @FXML + private CopyableTextField address; + + @FXML + private TextField label; + + @FXML + private TextField amount; + + @FXML + private ComboBox amountUnit; + + @FXML + private FiatLabel fiatAmount; + + @FXML + private Button maxButton; + + @FXML + private Button addPaymentButton; + + private final ChangeListener amountListener = new ChangeListener<>() { + @Override + public void changed(ObservableValue observable, String oldValue, String newValue) { + if(sendController.getUtxoSelector() instanceof MaxUtxoSelector) { + sendController.utxoSelectorProperty().setValue(null); + } + + Long recipientValueSats = getRecipientValueSats(); + if(recipientValueSats != null) { + setFiatAmount(AppController.getFiatCurrencyExchangeRate(), recipientValueSats); + } else { + fiatAmount.setText(""); + } + + sendController.updateTransaction(); + } + }; + + @Override + public void initialize(URL location, ResourceBundle resources) { + EventManager.get().register(this); + } + + public void setSendController(SendController sendController) { + this.sendController = sendController; + this.validationSupport = sendController.getValidationSupport(); + } + + @Override + public void initializeView() { + address.textProperty().addListener((observable, oldValue, newValue) -> { + revalidate(amount, amountListener); + maxButton.setDisable(!isValidRecipientAddress()); + sendController.updateTransaction(); + + if(validationSupport != null) { + validationSupport.setErrorDecorationEnabled(true); + } + }); + + label.textProperty().addListener((observable, oldValue, newValue) -> { + sendController.getCreateButton().setDisable(sendController.getWalletTransaction() == null || newValue == null || newValue.isEmpty() || sendController.isInsufficientFeeRate()); + sendController.updateTransaction(); + }); + + amount.setTextFormatter(new CoinTextFormatter()); + amount.textProperty().addListener(amountListener); + + amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(sendController.getBitcoinUnit(Config.get().getBitcoinUnit())) ? 0 : 1); + amountUnit.valueProperty().addListener((observable, oldValue, newValue) -> { + Long value = getRecipientValueSats(oldValue); + if(value != null) { + DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); + df.setMaximumFractionDigits(8); + amount.setText(df.format(newValue.getValue(value))); + } + }); + + maxButton.setDisable(!isValidRecipientAddress()); + sendController.utxoLabelSelectionProperty().addListener((observable, oldValue, newValue) -> { + maxButton.setText("Max" + newValue); + }); + + Optional firstTab = sendController.getPaymentTabs().getTabs().stream().findFirst(); + if(firstTab.isPresent()) { + PaymentController controller = (PaymentController)firstTab.get().getUserData(); + String firstLabel = controller.label.getText(); + label.setText(firstLabel); + } + + addValidation(validationSupport); + } + + private void addValidation(ValidationSupport validationSupport) { + this.validationSupport = validationSupport; + + validationSupport.registerValidator(address, Validator.combine( + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !newValue.isEmpty() && !isValidRecipientAddress()) + )); + validationSupport.registerValidator(label, Validator.combine( + Validator.createEmptyValidator("Label is required") + )); + validationSupport.registerValidator(amount, Validator.combine( + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", sendController.isInsufficientInputs()), + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold()) + )); + } + + private boolean isValidRecipientAddress() { + try { + getRecipientAddress(); + return true; + } catch (InvalidAddressException e) { + return false; + } + } + + private Address getRecipientAddress() throws InvalidAddressException { + return Address.fromString(address.getText()); + } + + private Long getRecipientValueSats() { + return getRecipientValueSats(amountUnit.getSelectionModel().getSelectedItem()); + } + + private Long getRecipientValueSats(BitcoinUnit bitcoinUnit) { + if(amount.getText() != null && !amount.getText().isEmpty()) { + double fieldValue = Double.parseDouble(amount.getText().replaceAll(",", "")); + return bitcoinUnit.getSatsValue(fieldValue); + } + + return null; + } + + private void setRecipientValueSats(long recipientValue) { + amount.textProperty().removeListener(amountListener); + DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); + df.setMaximumFractionDigits(8); + amount.setText(df.format(amountUnit.getValue().getValue(recipientValue))); + amount.textProperty().addListener(amountListener); + } + + private long getRecipientDustThreshold() { + Address address; + try { + address = getRecipientAddress(); + } catch(InvalidAddressException e) { + address = new P2PKHAddress(new byte[20]); + } + + TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript()); + return address.getScriptType().getDustThreshold(txOutput, sendController.getFeeRate()); + } + + private void setFiatAmount(CurrencyRate currencyRate, Long amount) { + if(amount != null && currencyRate != null && currencyRate.isAvailable()) { + fiatAmount.set(currencyRate, amount); + } + } + + public void revalidate() { + revalidate(amount, amountListener); + } + + private void revalidate(TextField field, ChangeListener listener) { + field.textProperty().removeListener(listener); + String amt = field.getText(); + field.setText(amt + "0"); + field.setText(amt); + field.textProperty().addListener(listener); + } + + public boolean isValidPayment() { + try { + getPayment(); + return true; + } catch(IllegalStateException e) { + return false; + } + } + + public Payment getPayment() { + return getPayment(false); + } + + public Payment getPayment(boolean sendAll) { + try { + Address address = getRecipientAddress(); + Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats(); + + if(!label.getText().isEmpty() && value != null && value > getRecipientDustThreshold()) { + return new Payment(address, label.getText(), value, sendAll); + } + } catch(InvalidAddressException e) { + //ignore + } + + throw new IllegalStateException("Invalid payment specified"); + } + + public void setPayment(Payment payment) { + if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) { + setRecipientValueSats(payment.getAmount()); + setFiatAmount(AppController.getFiatCurrencyExchangeRate(), payment.getAmount()); + } + } + + public void clear() { + address.setText(""); + label.setText(""); + + amount.textProperty().removeListener(amountListener); + amount.setText(""); + amount.textProperty().addListener(amountListener); + + fiatAmount.setText(""); + } + + public void setMaxInput(ActionEvent event) { + UtxoSelector utxoSelector = sendController.getUtxoSelector(); + if(utxoSelector == null) { + MaxUtxoSelector maxUtxoSelector = new MaxUtxoSelector(); + sendController.utxoSelectorProperty().set(maxUtxoSelector); + } + + try { + List payments = new ArrayList<>(); + for(Tab tab : sendController.getPaymentTabs().getTabs()) { + PaymentController controller = (PaymentController)tab.getUserData(); + if(controller != this) { + payments.add(controller.getPayment()); + } else { + payments.add(getPayment(true)); + } + } + sendController.updateTransaction(payments); + } catch(IllegalStateException e) { + //ignore, validation errors + } + } + + public void scanQrAddress(ActionEvent event) { + QRScanDialog qrScanDialog = new QRScanDialog(); + Optional optionalResult = qrScanDialog.showAndWait(); + if(optionalResult.isPresent()) { + QRScanDialog.Result result = optionalResult.get(); + if(result.uri != null) { + if(result.uri.getAddress() != null) { + address.setText(result.uri.getAddress().toString()); + } + if(result.uri.getLabel() != null) { + label.setText(result.uri.getLabel()); + } + if(result.uri.getAmount() != null) { + setRecipientValueSats(result.uri.getAmount()); + } + sendController.updateTransaction(); + } + } + } + + public void addPayment(ActionEvent event) { + sendController.addPaymentTab(); + } + + public Button getAddPaymentButton() { + return addPaymentButton; + } + + @Subscribe + public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) { + BitcoinUnit unit = sendController.getBitcoinUnit(event.getBitcoinUnit()); + amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1); + } + + @Subscribe + public void fiatCurrencySelected(FiatCurrencySelectedEvent event) { + if(event.getExchangeSource() == ExchangeSource.NONE) { + fiatAmount.setCurrency(null); + fiatAmount.setBtcRate(0.0); + } + } + + @Subscribe + public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) { + setFiatAmount(event.getCurrencyRate(), getRecipientValueSats()); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index 58a9bb0c..10071044 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -17,17 +17,18 @@ import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.ExchangeSource; import com.sparrowwallet.sparrow.net.ElectrumServer; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleObjectProperty; +import javafx.application.Platform; +import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; +import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; import javafx.fxml.Initializable; import javafx.scene.Node; import javafx.scene.control.*; +import javafx.scene.layout.StackPane; import javafx.util.StringConverter; import org.controlsfx.validation.ValidationResult; import org.controlsfx.validation.ValidationSupport; @@ -36,6 +37,7 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.net.URL; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; @@ -51,22 +53,7 @@ public class SendController extends WalletFormController implements Initializabl public static final double FALLBACK_FEE_RATE = 20000d / 1000; @FXML - private CopyableTextField address; - - @FXML - private TextField label; - - @FXML - private TextField amount; - - @FXML - private ComboBox amountUnit; - - @FXML - private FiatLabel fiatAmount; - - @FXML - private Button maxButton; + private TabPane paymentTabs; @FXML private Slider targetBlocks; @@ -95,6 +82,8 @@ public class SendController extends WalletFormController implements Initializabl @FXML private Button createButton; + private StackPane tabHeader; + private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false); private final ObjectProperty utxoSelectorProperty = new SimpleObjectProperty<>(null); @@ -107,23 +96,7 @@ public class SendController extends WalletFormController implements Initializabl private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false); - private final ChangeListener amountListener = new ChangeListener<>() { - @Override - public void changed(ObservableValue observable, String oldValue, String newValue) { - if(utxoSelectorProperty.get() instanceof MaxUtxoSelector) { - utxoSelectorProperty.setValue(null); - } - - Long recipientValueSats = getRecipientValueSats(); - if(recipientValueSats != null) { - setFiatAmount(AppController.getFiatCurrencyExchangeRate(), recipientValueSats); - } else { - fiatAmount.setText(""); - } - - updateTransaction(); - } - }; + private final StringProperty utxoLabelSelectionProperty = new SimpleStringProperty(""); private final ChangeListener feeListener = new ChangeListener<>() { @Override @@ -157,7 +130,10 @@ public class SendController extends WalletFormController implements Initializabl targetBlocks.setTooltip(tooltip); userFeeSet.set(false); - revalidate(amount, amountListener); + for(Tab tab : paymentTabs.getTabs()) { + PaymentController controller = (PaymentController)tab.getUserData(); + controller.revalidate(); + } updateTransaction(); } }; @@ -171,42 +147,44 @@ public class SendController extends WalletFormController implements Initializabl @Override public void initializeView() { - address.textProperty().addListener((observable, oldValue, newValue) -> { - revalidate(amount, amountListener); - maxButton.setDisable(!isValidRecipientAddress()); + addValidation(); + + addPaymentTab(); + Platform.runLater(() -> { + StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area"); + if(stackPane != null) { + tabHeader = stackPane; + tabHeader.managedProperty().bind(tabHeader.visibleProperty()); + tabHeader.setVisible(false); + paymentTabs.getStyleClass().remove("initial"); + } + }); + + paymentTabs.getTabs().addListener((ListChangeListener) c -> { + if(tabHeader != null) { + tabHeader.setVisible(c.getList().size() > 1); + } + + if(c.getList().size() > 1) { + if(!paymentTabs.getStyleClass().contains("multiple-tabs")) { + paymentTabs.getStyleClass().add("multiple-tabs"); + } + paymentTabs.getTabs().forEach(tab -> tab.setClosable(true)); + } else { + paymentTabs.getStyleClass().remove("multiple-tabs"); + Tab remainingTab = paymentTabs.getTabs().get(0); + remainingTab.setClosable(false); + remainingTab.setText("1"); + } + updateTransaction(); - - if(validationSupport != null) { - validationSupport.setErrorDecorationEnabled(true); - } }); - label.textProperty().addListener((observable, oldValue, newValue) -> { - createButton.setDisable(walletTransactionProperty.get() == null || newValue == null || newValue.isEmpty() || isInsufficientFeeRate()); - }); - - amount.setTextFormatter(new CoinTextFormatter()); - amount.textProperty().addListener(amountListener); - - BitcoinUnit unit = Config.get().getBitcoinUnit(); - if(unit == null || unit.equals(BitcoinUnit.AUTO)) { - unit = getWalletForm().getWallet().getAutoUnit(); - } - - amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1); - amountUnit.valueProperty().addListener((observable, oldValue, newValue) -> { - Long value = getRecipientValueSats(oldValue); - if(value != null) { - DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); - df.setMaximumFractionDigits(8); - amount.setText(df.format(newValue.getValue(value))); - } - }); - - maxButton.setDisable(!isValidRecipientAddress()); - insufficientInputsProperty.addListener((observable, oldValue, newValue) -> { - revalidate(amount, amountListener); + for(Tab tab : paymentTabs.getTabs()) { + PaymentController controller = (PaymentController)tab.getUserData(); + controller.revalidate(); + } revalidate(fee, feeListener); }); @@ -244,6 +222,7 @@ public class SendController extends WalletFormController implements Initializabl fee.setTextFormatter(new CoinTextFormatter()); fee.textProperty().addListener(feeListener); + BitcoinUnit unit = getBitcoinUnit(Config.get().getBitcoinUnit()); feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1); feeAmountUnit.valueProperty().addListener((observable, oldValue, newValue) -> { Long value = getFeeValueSats(oldValue); @@ -265,6 +244,10 @@ public class SendController extends WalletFormController implements Initializabl } }); + utxoLabelSelectionProperty.addListener((observable, oldValue, newValue) -> { + clearButton.setText("Clear" + newValue); + }); + utxoSelectorProperty.addListener((observable, oldValue, utxoSelector) -> { updateMaxClearButtons(utxoSelector, utxoFilterProperty.get()); }); @@ -275,9 +258,10 @@ public class SendController extends WalletFormController implements Initializabl walletTransactionProperty.addListener((observable, oldValue, walletTransaction) -> { if(walletTransaction != null) { - if(getRecipientValueSats() == null || walletTransaction.getPayments().get(0).getAmount() != getRecipientValueSats()) { - setRecipientValueSats(walletTransaction.getPayments().get(0).getAmount()); - setFiatAmount(AppController.getFiatCurrencyExchangeRate(), walletTransaction.getPayments().get(0).getAmount()); + for(int i = 0; i < paymentTabs.getTabs().size(); i++) { + Payment payment = walletTransaction.getPayments().get(i); + PaymentController controller = (PaymentController)paymentTabs.getTabs().get(i).getUserData(); + controller.setPayment(payment); } double feeRate = walletTransaction.getFeeRate(); @@ -291,24 +275,24 @@ public class SendController extends WalletFormController implements Initializabl } transactionDiagram.update(walletTransaction); - createButton.setDisable(walletTransaction == null || label.getText().isEmpty() || isInsufficientFeeRate()); + createButton.setDisable(walletTransaction == null || isInsufficientFeeRate()); }); + } - addValidation(); + public BitcoinUnit getBitcoinUnit(BitcoinUnit bitcoinUnit) { + BitcoinUnit unit = bitcoinUnit; + if(unit == null || unit.equals(BitcoinUnit.AUTO)) { + unit = getWalletForm().getWallet().getAutoUnit(); + } + return unit; + } + + public ValidationSupport getValidationSupport() { + return validationSupport; } private void addValidation() { validationSupport = new ValidationSupport(); - validationSupport.registerValidator(address, Validator.combine( - (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !newValue.isEmpty() && !isValidRecipientAddress()) - )); - validationSupport.registerValidator(label, Validator.combine( - Validator.createEmptyValidator("Label is required") - )); - validationSupport.registerValidator(amount, Validator.combine( - (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", insufficientInputsProperty.get()), - (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold()) - )); validationSupport.registerValidator(fee, Validator.combine( (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", userFeeSet.get() && insufficientInputsProperty.get()), (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0), @@ -316,20 +300,64 @@ public class SendController extends WalletFormController implements Initializabl )); validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + validationSupport.setErrorDecorationEnabled(false); } - private void updateTransaction() { - updateTransaction(false); + public Tab addPaymentTab() { + Tab tab = getPaymentTab(); + paymentTabs.getTabs().add(tab); + paymentTabs.getSelectionModel().select(tab); + return tab; } - private void updateTransaction(boolean sendAll) { + public Tab getPaymentTab() { + Tab tab = new Tab(Integer.toString(paymentTabs.getTabs().size() + 1)); + try { - Address recipientAddress = getRecipientAddress(); - long recipientDustThreshold = getRecipientDustThreshold(); - Long recipientAmount = sendAll ? Long.valueOf(recipientDustThreshold + 1) : getRecipientValueSats(); - if(recipientAmount != null && recipientAmount > recipientDustThreshold && (!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0))) { + FXMLLoader paymentLoader = new FXMLLoader(AppController.class.getResource("wallet/payment.fxml")); + tab.setContent(paymentLoader.load()); + PaymentController controller = paymentLoader.getController(); + controller.setSendController(this); + controller.initializeView(); + tab.setUserData(controller); + return tab; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public List getPayments() { + List payments = new ArrayList<>(); + for(Tab tab : paymentTabs.getTabs()) { + PaymentController controller = (PaymentController)tab.getUserData(); + payments.add(controller.getPayment()); + } + + return payments; + } + + public void updateTransaction() { + updateTransaction(null); + } + + public void updateTransaction(boolean sendAll) { + try { + if(paymentTabs.getTabs().size() == 1) { + PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData(); + updateTransaction(List.of(controller.getPayment(sendAll))); + } else { + updateTransaction(null); + } + } catch(IllegalStateException e) { + //ignore + } + } + + public void updateTransaction(List transactionPayments) { + try { + List payments = transactionPayments != null ? transactionPayments : getPayments(); + if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) { Wallet wallet = getWalletForm().getWallet(); - List payments = List.of(new Payment(recipientAddress, label.getText(), recipientAmount, sendAll)); Long userFee = userFeeSet.get() ? getFeeValueSats() : null; Integer currentBlockHeight = AppController.getCurrentBlockHeight(); boolean groupByAddress = Config.get().isGroupByAddress(); @@ -340,9 +368,9 @@ public class SendController extends WalletFormController implements Initializabl return; } - } catch (InvalidAddressException e) { + } catch(InvalidAddressException | IllegalStateException e) { //ignore - } catch (InsufficientFundsException e) { + } catch(InsufficientFundsException e) { insufficientInputsProperty.set(true); } @@ -355,7 +383,7 @@ public class SendController extends WalletFormController implements Initializabl } Wallet wallet = getWalletForm().getWallet(); - long noInputsFee = wallet.getNoInputsFee(getRecipientAddress(), getFeeRate()); + long noInputsFee = wallet.getNoInputsFee(getPayments(), getFeeRate()); long costOfChange = wallet.getCostOfChange(getFeeRate(), getMinimumFeeRate()); return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee)); @@ -370,40 +398,6 @@ public class SendController extends WalletFormController implements Initializabl return Collections.emptyList(); } - private boolean isValidRecipientAddress() { - try { - getRecipientAddress(); - return true; - } catch (InvalidAddressException e) { - return false; - } - } - - private Address getRecipientAddress() throws InvalidAddressException { - return Address.fromString(address.getText()); - } - - private Long getRecipientValueSats() { - return getRecipientValueSats(amountUnit.getSelectionModel().getSelectedItem()); - } - - private Long getRecipientValueSats(BitcoinUnit bitcoinUnit) { - if(amount.getText() != null && !amount.getText().isEmpty()) { - double fieldValue = Double.parseDouble(amount.getText().replaceAll(",", "")); - return bitcoinUnit.getSatsValue(fieldValue); - } - - return null; - } - - private void setRecipientValueSats(long recipientValue) { - amount.textProperty().removeListener(amountListener); - DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); - df.setMaximumFractionDigits(8); - amount.setText(df.format(amountUnit.getValue().getValue(recipientValue))); - amount.textProperty().addListener(amountListener); - } - private Long getFeeValueSats() { return getFeeValueSats(feeAmountUnit.getSelectionModel().getSelectedItem()); } @@ -464,7 +458,7 @@ public class SendController extends WalletFormController implements Initializabl return retrievedFeeRates; } - private Double getFeeRate() { + public Double getFeeRate() { return getTargetBlocksFeeRates().get(getTargetBlocks()); } @@ -474,7 +468,7 @@ public class SendController extends WalletFormController implements Initializabl return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE); } - private boolean isInsufficientFeeRate() { + public boolean isInsufficientFeeRate() { return walletTransactionProperty.get() != null && walletTransactionProperty.get().getFeeRate() < AppController.getMinimumRelayFeeRate(); } @@ -486,87 +480,40 @@ public class SendController extends WalletFormController implements Initializabl return targetBlocks.lookup(".thumb"); } - public void setMaxInput(ActionEvent event) { - UtxoSelector utxoSelector = utxoSelectorProperty.get(); - if(utxoSelector == null) { - MaxUtxoSelector maxUtxoSelector = new MaxUtxoSelector(); - utxoSelectorProperty.set(maxUtxoSelector); - } - - updateTransaction(true); - } - - private void setFiatAmount(CurrencyRate currencyRate, Long amount) { - if(amount != null && currencyRate != null && currencyRate.isAvailable()) { - fiatAmount.set(currencyRate, amount); - } - } - private void setFiatFeeAmount(CurrencyRate currencyRate, Long amount) { if(amount != null && currencyRate != null && currencyRate.isAvailable()) { fiatFeeAmount.set(currencyRate, amount); } } - private long getRecipientDustThreshold() { - Address address; - try { - address = getRecipientAddress(); - } catch(InvalidAddressException e) { - address = new P2PKHAddress(new byte[20]); - } - - TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript()); - return address.getScriptType().getDustThreshold(txOutput, getFeeRate()); - } - private void updateMaxClearButtons(UtxoSelector utxoSelector, UtxoFilter utxoFilter) { if(utxoSelector instanceof PresetUtxoSelector) { PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector; int num = presetUtxoSelector.getPresetUtxos().size(); String selection = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " selected)"; - maxButton.setText("Max" + selection); - clearButton.setText("Clear" + selection); + utxoLabelSelectionProperty.set(selection); } else if(utxoFilter instanceof ExcludeUtxoFilter) { ExcludeUtxoFilter excludeUtxoFilter = (ExcludeUtxoFilter)utxoFilter; int num = excludeUtxoFilter.getExcludedUtxos().size(); String exclusion = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " excluded)"; - maxButton.setText("Max" + exclusion); - clearButton.setText("Clear" + exclusion); + utxoLabelSelectionProperty.set(exclusion); } else { - maxButton.setText("Max"); - clearButton.setText("Clear"); - } - } - - public void scanQrAddress(ActionEvent event) { - QRScanDialog qrScanDialog = new QRScanDialog(); - Optional optionalResult = qrScanDialog.showAndWait(); - if(optionalResult.isPresent()) { - QRScanDialog.Result result = optionalResult.get(); - if(result.uri != null) { - if(result.uri.getAddress() != null) { - address.setText(result.uri.getAddress().toString()); - } - if(result.uri.getLabel() != null) { - label.setText(result.uri.getLabel()); - } - if(result.uri.getAmount() != null) { - setRecipientValueSats(result.uri.getAmount()); - } - } + utxoLabelSelectionProperty.set(""); } } public void clear(ActionEvent event) { - address.setText(""); - label.setText(""); - - amount.textProperty().removeListener(amountListener); - amount.setText(""); - amount.textProperty().addListener(amountListener); - - fiatAmount.setText(""); + boolean firstTab = true; + for(Iterator iterator = paymentTabs.getTabs().iterator(); iterator.hasNext(); ) { + PaymentController controller = (PaymentController)iterator.next().getUserData(); + if(firstTab) { + controller.clear(); + firstTab = false; + } else { + EventManager.get().unregister(controller); + iterator.remove(); + } + } fee.textProperty().removeListener(feeListener); fee.setText(""); @@ -584,6 +531,46 @@ public class SendController extends WalletFormController implements Initializabl validationSupport.setErrorDecorationEnabled(false); } + public UtxoSelector getUtxoSelector() { + return utxoSelectorProperty.get(); + } + + public ObjectProperty utxoSelectorProperty() { + return utxoSelectorProperty; + } + + public boolean isInsufficientInputs() { + return insufficientInputsProperty.get(); + } + + public BooleanProperty insufficientInputsProperty() { + return insufficientInputsProperty; + } + + public WalletTransaction getWalletTransaction() { + return walletTransactionProperty.get(); + } + + public ObjectProperty walletTransactionProperty() { + return walletTransactionProperty; + } + + public String getUtxoLabelSelection() { + return utxoLabelSelectionProperty.get(); + } + + public StringProperty utxoLabelSelectionProperty() { + return utxoLabelSelectionProperty; + } + + public TabPane getPaymentTabs() { + return paymentTabs; + } + + public Button getCreateButton() { + return createButton; + } + private void revalidate(TextField field, ChangeListener listener) { field.textProperty().removeListener(listener); String amt = field.getText(); @@ -605,7 +592,7 @@ public class SendController extends WalletFormController implements Initializabl addWalletTransactionNodes(); createdWalletTransactionProperty.set(walletTransactionProperty.get()); PSBT psbt = walletTransactionProperty.get().createPSBT(); - EventManager.get().post(new ViewPSBTEvent(label.getText(), psbt)); + EventManager.get().post(new ViewPSBTEvent(walletTransactionProperty.get().getPayments().get(0).getLabel(), psbt)); } private void addWalletTransactionNodes() { @@ -683,25 +670,20 @@ public class SendController extends WalletFormController implements Initializabl @Subscribe public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) { - BitcoinUnit unit = event.getBitcoinUnit(); - if(unit == null || unit.equals(BitcoinUnit.AUTO)) { - unit = getWalletForm().getWallet().getAutoUnit(); - } - amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1); + BitcoinUnit unit = getBitcoinUnit(event.getBitcoinUnit()); feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1); } @Subscribe public void fiatCurrencySelected(FiatCurrencySelectedEvent event) { if(event.getExchangeSource() == ExchangeSource.NONE) { - fiatAmount.setCurrency(null); - fiatAmount.setBtcRate(0.0); + fiatFeeAmount.setCurrency(null); + fiatFeeAmount.setBtcRate(0.0); } } @Subscribe public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) { - setFiatAmount(event.getCurrencyRate(), getRecipientValueSats()); setFiatFeeAmount(event.getCurrencyRate(), getFeeValueSats()); } diff --git a/src/main/resources/com/sparrowwallet/sparrow/darktheme.css b/src/main/resources/com/sparrowwallet/sparrow/darktheme.css index 1bc82592..a23d0ce5 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/darktheme.css +++ b/src/main/resources/com/sparrowwallet/sparrow/darktheme.css @@ -153,3 +153,9 @@ -fx-border-color: derive(-fx-base, -2%); /*-fx-border-width: 1;*/ } + +.root .multiple-tabs { + -fx-background-color: derive(-fx-background, -4%); + -fx-border-width: 1px 0px 1px 0px; + -fx-border-color: derive(-fx-background, -10%); +} diff --git a/src/main/resources/com/sparrowwallet/sparrow/general.css b/src/main/resources/com/sparrowwallet/sparrow/general.css index d23eb597..f3cbd059 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/general.css +++ b/src/main/resources/com/sparrowwallet/sparrow/general.css @@ -27,6 +27,10 @@ -fx-padding: 3 5; } +.form .fieldset.header .legend { + -fx-padding: 0 0 15px 0; +} + .id, .fixed-width { -fx-font-size: 13px; -fx-font-family: 'Roboto Mono'; diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.css new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml new file mode 100644 index 00000000..f07c3cba --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + +
+
+
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml index b004fe9d..1338ba97 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml @@ -33,7 +33,7 @@
-
+
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css index 52caa91c..0d545d40 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css @@ -1,3 +1,7 @@ +.title-form .fieldset { + -fx-padding: 0 0 0 0; +} + .form .fieldset:horizontal .field { -fx-pref-height: 40px; } @@ -6,6 +10,24 @@ -fx-pref-width: 90px; } +#paymentTabs { + -fx-max-height: 154px; +} + +.initial .tab-header-area { + visibility: hidden; +} + +.multiple-tabs { + -fx-background-color: derive(-fx-background, 20%); + -fx-border-width: 1px 0px 1px 0px; + -fx-border-color: derive(-fx-background, -5%); +} + +.multiple-tabs .send-form { + -fx-padding: 9px 0px 9px 0px; +} + .amount-field { -fx-pref-width: 140px; -fx-min-width: 140px; @@ -51,7 +73,7 @@ -fx-stroke: transparent; } -#transactionDiagram .input-label, #transactionDiagram .recipient-label, #transactionDiagram .change-label, #transactionDiagram .fee-tooltip { +#transactionDiagram .input-label, #transactionDiagram .recipient-label, #transactionDiagram .change-label, #transactionDiagram .fee-tooltip, #transactionDiagram .transaction-tooltip { -fx-font-size: 13px; -fx-font-family: 'Roboto Mono'; } diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml index 8d3bf25a..6b76c6c3 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml @@ -37,40 +37,16 @@ - -
- - - - - - - - - - - - - - - - -
- -
-
- -
-
+ + +
+
+ + +
+ +
+
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/settings.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/settings.fxml index 74380f19..162c1d0f 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/settings.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/settings.fxml @@ -28,7 +28,7 @@ -
+
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml index edfe5a1c..39ebf611 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/transactions.fxml @@ -30,7 +30,7 @@ -
+