diff --git a/build.gradle b/build.gradle index 55f16c4e..cb0981b5 100644 --- a/build.gradle +++ b/build.gradle @@ -60,7 +60,7 @@ dependencies { } implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8") implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7') - implementation('org.controlsfx:controlsfx:11.0.2' ) { + implementation('org.controlsfx:controlsfx:11.1.0' ) { exclude group: 'org.openjfx', module: 'javafx-base' exclude group: 'org.openjfx', module: 'javafx-graphics' exclude group: 'org.openjfx', module: 'javafx-controls' diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 834d7f79..98d93fa0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -135,6 +135,9 @@ public class AppController implements Initializable { @FXML private MenuItem refreshWallet; + @FXML + private MenuItem sendToMany; + @FXML private StackPane rootStack; @@ -266,6 +269,7 @@ public class AppController implements Initializable { savePSBT.visibleProperty().bind(saveTransaction.visibleProperty().not()); exportWallet.setDisable(true); refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not()))); + sendToMany.disableProperty().bind(exportWallet.disableProperty()); setServerType(Config.get().getServerType()); serverToggle.setSelected(isConnected()); @@ -1030,6 +1034,28 @@ public class AppController implements Initializable { messageSignDialog.showAndWait(); } + public void sendToMany(ActionEvent event) { + Tab selectedTab = tabs.getSelectionModel().getSelectedItem(); + TabData tabData = (TabData)selectedTab.getUserData(); + if(tabData.getType() == TabData.TabType.WALLET) { + WalletTabData walletTabData = (WalletTabData) tabData; + Wallet wallet = walletTabData.getWallet(); + BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit(); + if(bitcoinUnit == BitcoinUnit.AUTO) { + bitcoinUnit = wallet.getAutoUnit(); + } + + SendToManyDialog sendToManyDialog = new SendToManyDialog(bitcoinUnit); + Optional> optPayments = sendToManyDialog.showAndWait(); + optPayments.ifPresent(payments -> { + if(!payments.isEmpty()) { + EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet()))); + Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(wallet, payments))); + } + }); + } + } + public void minimizeToTray(ActionEvent event) { AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow()); } @@ -1422,6 +1448,7 @@ public class AppController implements Initializable { } exportWallet.setDisable(true); showLoadingLog.setDisable(true); + showUtxosChart.setDisable(true); showTxHex.setDisable(false); } else if(event instanceof WalletTabSelectedEvent) { WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event; @@ -1430,6 +1457,7 @@ public class AppController implements Initializable { saveTransaction.setDisable(true); exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid()); showLoadingLog.setDisable(false); + showUtxosChart.setDisable(false); showTxHex.setDisable(true); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddressStringConverter.java b/src/main/java/com/sparrowwallet/sparrow/control/AddressStringConverter.java new file mode 100644 index 00000000..77923651 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddressStringConverter.java @@ -0,0 +1,37 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; +import javafx.util.StringConverter; + +public class AddressStringConverter extends StringConverter
{ + @Override + public Address fromString(String value) { + // If the specified value is null or zero-length, return null + if(value == null) { + return null; + } + + value = value.trim(); + + if (value.length() < 1) { + return null; + } + + try { + return Address.fromString(value); + } catch(InvalidAddressException e) { + throw new IllegalArgumentException(e); + } + } + + @Override + public String toString(Address value) { + // If the specified value is null, return a zero-length String + if(value == null) { + return ""; + } + + return value.toString(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java new file mode 100644 index 00000000..0383ef7c --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java @@ -0,0 +1,298 @@ +package com.sparrowwallet.sparrow.control; + +import com.csvreader.CsvReader; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.Payment; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.stage.FileChooser; +import javafx.util.StringConverter; +import org.controlsfx.control.spreadsheet.*; +import org.controlsfx.glyphfont.Glyph; +import org.controlsfx.tools.Platform; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class SendToManyDialog extends Dialog> { + private final BitcoinUnit bitcoinUnit; + private final SpreadsheetView spreadsheetView; + public static final AddressCellType ADDRESS = new AddressCellType(); + + public SendToManyDialog(BitcoinUnit bitcoinUnit) { + this.bitcoinUnit = bitcoinUnit; + + final DialogPane dialogPane = new SendToManyDialogPane(); + setDialogPane(dialogPane); + setTitle("Send to Many"); + dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary."); + Image image = new Image("/image/sparrow-small.png"); + dialogPane.setGraphic(new ImageView(image)); + + List initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList()); + Grid grid = getGrid(initialPayments); + + spreadsheetView = new SpreadsheetView(grid); + spreadsheetView.getColumns().get(0).setPrefWidth(400); + spreadsheetView.getColumns().get(1).setPrefWidth(150); + spreadsheetView.getColumns().get(2).setPrefWidth(247); + dialogPane.setContent(spreadsheetView); + + dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load CSV", ButtonBar.ButtonData.LEFT); + dialogPane.getButtonTypes().add(loadCsvButtonType); + + setResultConverter((dialogButton) -> { + ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData(); + return data == ButtonBar.ButtonData.OK_DONE ? getPayments() : null; + }); + + dialogPane.setPrefWidth(850); + dialogPane.setPrefHeight(500); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + AppServices.moveToActiveWindowScreen(this); + } + + private Grid getGrid(List payments) { + int rowCount = payments.size(); + int columnCount = 3; + GridBase grid = new GridBase(rowCount, columnCount); + ObservableList> rows = FXCollections.observableArrayList(); + for(int row = 0; row < grid.getRowCount(); ++row) { + final ObservableList list = FXCollections.observableArrayList(); + + SpreadsheetCell addressCell = ADDRESS.createCell(row, 0, 1, 1, payments.get(row).getAddress()); + addressCell.getStyleClass().add("fixed-width"); + list.add(addressCell); + + double amount = (double)payments.get(row).getAmount(); + if(bitcoinUnit == BitcoinUnit.BTC) { + amount = amount / Transaction.SATOSHIS_PER_BITCOIN; + } + SpreadsheetCell amountCell = SpreadsheetCellType.DOUBLE.createCell(row, 1, 1, 1, amount < 0 ? null : amount); + amountCell.setFormat(bitcoinUnit == BitcoinUnit.BTC ? "0.00000000" : "###,###"); + amountCell.getStyleClass().add("number-value"); + if(Platform.getCurrent() == Platform.OSX) { + amountCell.getStyleClass().add("number-field"); + } + list.add(amountCell); + + list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, payments.get(row).getLabel())); + rows.add(list); + } + grid.setRows(rows); + grid.getColumnHeaders().setAll("Address", "Amount (" + bitcoinUnit.getLabel() + ")", "Label"); + + return grid; + } + + private List getPayments() { + List payments = new ArrayList<>(); + Grid grid = spreadsheetView.getGrid(); + String firstLabel = null; + for(int row = 0; row < grid.getRowCount(); row++) { + ObservableList rowCells = spreadsheetView.getItems().get(row); + Address address = (Address)rowCells.get(0).getItem(); + Double value = (Double)rowCells.get(1).getItem(); + String label = (String)rowCells.get(2).getItem(); + if(firstLabel == null) { + firstLabel = label; + } + if(label == null || label.isEmpty()) { + label = firstLabel; + } + + if(address != null && value != null) { + if(bitcoinUnit == BitcoinUnit.BTC) { + value = value * Transaction.SATOSHIS_PER_BITCOIN; + } + + payments.add(new Payment(address, label, value.longValue(), false)); + } + } + + return payments; + } + + private class SendToManyDialogPane extends DialogPane { + @Override + protected Node createButton(ButtonType buttonType) { + Node button; + if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) { + Button loadButton = new Button(buttonType.getText()); + loadButton.setGraphicTextGap(5); + loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP)); + final ButtonBar.ButtonData buttonData = buttonType.getButtonData(); + ButtonBar.setButtonData(loadButton, buttonData); + loadButton.setOnAction(event -> { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Open CSV"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"), + new FileChooser.ExtensionFilter("CSV", "*.csv") + ); + + AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450); + File file = fileChooser.showOpenDialog(this.getScene().getWindow()); + if(file != null) { + try { + List csvPayments = new ArrayList<>(); + try(Reader reader = new FileReader(file, StandardCharsets.UTF_8)) { + CsvReader csvReader = new CsvReader(reader); + while(csvReader.readRecord()) { + if(csvReader.getColumnCount() < 2) { + continue; + } + + try { + long amount; + if(bitcoinUnit == BitcoinUnit.BTC) { + double doubleAmount = Double.parseDouble(csvReader.get(1).replace(",", "")); + amount = (long)(doubleAmount * Transaction.SATOSHIS_PER_BITCOIN); + } else { + amount = Long.parseLong(csvReader.get(1).replace(",", "")); + } + Address address = Address.fromString(csvReader.get(0)); + String label = csvReader.get(2); + csvPayments.add(new Payment(address, label, amount, false)); + } catch(NumberFormatException e) { + //ignore and continue - probably a header line + } catch(InvalidAddressException e) { + AppServices.showErrorDialog("Invalid Address", e.getMessage()); + } + } + + if(csvPayments.isEmpty()) { + AppServices.showErrorDialog("No recipients found", "No valid recipients were found. Use a CSV file with three columns, and ensure amounts are in " + bitcoinUnit.getLabel() + "."); + return; + } + + spreadsheetView.setGrid(getGrid(csvPayments)); + } + } catch(IOException e) { + AppServices.showErrorDialog("Cannot load CSV", e.getMessage()); + } + } + }); + + button = loadButton; + } else { + button = super.createButton(buttonType); + } + + return button; + } + + private Glyph getGlyph(FontAwesome5.Glyph glyphName) { + Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName); + glyph.setFontSize(11); + return glyph; + } + } + + public static class AddressCellType extends SpreadsheetCellType
{ + public AddressCellType() { + this(new StringConverterWithFormat<>(new AddressStringConverter()) { + @Override + public String toString(Address item) { + return toStringFormat(item, ""); //$NON-NLS-1$ + } + + @Override + public Address fromString(String str) { + if(str == null || str.isEmpty()) { //$NON-NLS-1$ + return null; + } else { + return myConverter.fromString(str); + } + } + + @Override + public String toStringFormat(Address item, String format) { + try { + if(item == null) { + return ""; //$NON-NLS-1$ + } else { + return item.toString(); + } + } catch (Exception ex) { + return myConverter.toString(item); + } + } + }); + } + + public AddressCellType(StringConverter
converter) { + super(converter); + } + + @Override + public String toString() { + return "address"; + } + + public SpreadsheetCell createCell(final int row, final int column, final int rowSpan, final int columnSpan, + final Address value) { + SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this); + cell.setItem(value); + return cell; + } + + @Override + public SpreadsheetCellEditor createEditor(SpreadsheetView view) { + return new SpreadsheetCellEditor.StringEditor(view); + } + + @Override + public boolean match(Object value, Object... options) { + if(value instanceof Address) + return true; + else { + try { + converter.fromString(value == null ? null : value.toString()); + return true; + } catch (Exception e) { + return false; + } + } + } + + @Override + public Address convertValue(Object value) { + if(value instanceof Address) + return (Address)value; + else { + try { + return converter.fromString(value == null ? null : value.toString()); + } catch (Exception e) { + return null; + } + } + } + + @Override + public String toString(Address item) { + return converter.toString(item); + } + + @Override + public String toString(Address item, String format) { + return ((StringConverterWithFormat
)converter).toStringFormat(item, format); + } + }; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index e40f2bd3..a399d712 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -1084,8 +1084,10 @@ public class SendController extends WalletFormController implements Initializabl if(event.getWallet().equals(getWalletForm().getWallet())) { if(event.getPayments() != null) { clear(null); - setPayments(event.getPayments()); - updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax)); + Platform.runLater(() -> { + setPayments(event.getPayments()); + updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax)); + }); } } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index a4503225..9bb8584f 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -98,6 +98,7 @@ + diff --git a/src/main/resources/com/sparrowwallet/sparrow/general.css b/src/main/resources/com/sparrowwallet/sparrow/general.css index 0cd42c14..930469ce 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/general.css +++ b/src/main/resources/com/sparrowwallet/sparrow/general.css @@ -191,4 +191,45 @@ .number-field { -fx-font-family: 'Helvetica Neue', 'System Regular'; +} + +VerticalHeader > Label.selected { + -fx-background-color: -fx-accent; + -fx-text-fill : white; +} + +HorizontalHeaderColumn > TableColumnHeader.column-header.table-column.selected, +HorizontalHeaderColumn > TableColumnHeader.column-header.table-column.selected > Label { + -fx-background-color: -fx-accent; + -fx-text-fill : white; +} + +.spreadsheet-cell:filled:selected, +.spreadsheet-cell:filled:focused:selected, +.spreadsheet-cell:filled:focused:selected:hover { + -fx-background-color: transparent; + -fx-text-fill: -fx-text-inner-color; +} + +.spreadsheet-cell:hover, +.spreadsheet-cell:filled:focused { + -fx-background-color: transparent; +} + +.spreadsheet-cell { + -fx-border-color: #a9a9a9; +} + +.table-column > .number-value { + -fx-alignment: right; +} + +.selection-rectangle{ + -fx-fill: transparent; + -fx-stroke: -fx-accent; + -fx-stroke-width: 1; +} + +CellView > .text-input.text-field { + -fx-text-fill: -fx-text-inner-color; } \ No newline at end of file