diff --git a/drongo b/drongo index eb49c971..99440eda 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit eb49c9713375ac5f909d8ec3fa4dfddaf7d63ffe +Subproject commit 99440eda7f8a2b5a8adec68d32a704634157a3d3 diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index ba76f650..280fe004 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -3,16 +3,16 @@ package com.sparrowwallet.sparrow.control; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.TransactionOutput; import com.sparrowwallet.drongo.uri.BitcoinURI; -import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; -import com.sparrowwallet.drongo.wallet.Payment; -import com.sparrowwallet.drongo.wallet.WalletNode; -import com.sparrowwallet.drongo.wallet.WalletTransaction; +import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent; import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Group; @@ -38,6 +38,7 @@ public class TransactionDiagram extends GridPane { private static final int TOOLTIP_SHOW_DELAY = 50; private WalletTransaction walletTx; + private final BooleanProperty finalProperty = new SimpleBooleanProperty(false); public void update(WalletTransaction walletTx) { setMinHeight(getDiagramHeight()); @@ -105,7 +106,7 @@ public class TransactionDiagram extends GridPane { private Map getDisplayedUtxos() { Map selectedUtxos = walletTx.getSelectedUtxos(); - if(getPayjoinURI() != null) { + if(getPayjoinURI() != null && !selectedUtxos.containsValue(null)) { selectedUtxos = new LinkedHashMap<>(selectedUtxos); selectedUtxos.put(new PayjoinBlockTransactionHashIndex(), null); } @@ -228,18 +229,29 @@ public class TransactionDiagram extends GridPane { label.getStyleClass().add("input-label"); } - label.setGraphic(excludeUtxoButton); - label.setContentDisplay(ContentDisplay.LEFT); + if(!isFinal()) { + label.setGraphic(excludeUtxoButton); + label.setContentDisplay(ContentDisplay.LEFT); + } } else { if(input instanceof PayjoinBlockTransactionHashIndex) { tooltip.setText("Added once transaction is signed and sent to the payjoin server"); - } else { - AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex) input; + } else if(input instanceof AdditionalBlockTransactionHashIndex additionalReference) { StringJoiner joiner = new StringJoiner("\n"); for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) { joiner.add(getInputDescription(additionalInput)); } tooltip.setText(joiner.toString()); + } else { + if(walletTx.getInputTransactions() != null && walletTx.getInputTransactions().get(input.getHash()) != null) { + BlockTransaction blockTransaction = walletTx.getInputTransactions().get(input.getHash()); + TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int)input.getIndex()); + Address fromAddress = txOutput.getScript().getToAddress(); + tooltip.setText("Input of " + getSatsValue(txOutput.getValue()) + " sats\n" + input.getHashAsString() + ":" + input.getIndex() + (fromAddress != null ? "\n" + fromAddress : "")); + } else { + tooltip.setText(input.getHashAsString() + ":" + input.getIndex()); + } + label.getStyleClass().add("input-label"); } tooltip.getStyleClass().add("input-label"); } @@ -376,19 +388,22 @@ public class TransactionDiagram extends GridPane { outputsBox.setAlignment(Pos.CENTER_LEFT); outputsBox.getChildren().add(createSpacer()); + List outputNodes = new ArrayList<>(); for(Payment payment : displayedPayments) { Glyph outputGlyph = getOutputGlyph(payment); boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment; - String recipientDesc = labelledPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "..."; - Label recipientLabel = new Label(recipientDesc, outputGlyph); + payment.setLabel(getOutputLabel(payment)); + Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph); recipientLabel.getStyleClass().add("output-label"); recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label"); - Tooltip recipientTooltip = new Tooltip((walletTx.isConsolidationSend(payment) ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString())); + Wallet toWallet = getToWallet(payment); + Tooltip recipientTooltip = new Tooltip((toWallet == null ? (walletTx.isConsolidationSend(payment) ? "Consolidate " : "Pay ") : "Receive ") + + getSatsValue(payment.getAmount()) + " sats to " + + (payment instanceof AdditionalPayment ? "\n" + payment : (toWallet == null ? (payment.getLabel() == null ? "external address" : payment.getLabel()) : toWallet.getName()) + "\n" + payment.getAddress().toString())); recipientTooltip.getStyleClass().add("recipient-label"); recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); recipientLabel.setTooltip(recipientTooltip); - outputsBox.getChildren().add(recipientLabel); - outputsBox.getChildren().add(createSpacer()); + outputNodes.add(new OutputNode(recipientLabel, payment.getAddress())); } for(Map.Entry changeEntry : walletTx.getChangeMap().entrySet()) { @@ -397,29 +412,41 @@ public class TransactionDiagram extends GridPane { boolean overGapLimit = (changeNode.getIndex() - defaultChangeNode.getIndex()) > walletTx.getWallet().getGapLimit(); HBox actionBox = new HBox(); - String changeDesc = walletTx.getChangeAddress(changeNode).toString().substring(0, 8) + "..."; + Address changeAddress = walletTx.getChangeAddress(changeNode); + String changeDesc = changeAddress.toString().substring(0, 8) + "..."; Label changeLabel = new Label(changeDesc, overGapLimit ? getChangeWarningGlyph() : getChangeGlyph()); changeLabel.getStyleClass().addAll("output-label", "change-label"); Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(changeEntry.getValue()) + " sats to " + changeNode.getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : "")); changeTooltip.getStyleClass().add("change-label"); changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); changeLabel.setTooltip(changeTooltip); + actionBox.getChildren().add(changeLabel); - Button nextChangeAddressButton = new Button(""); - nextChangeAddressButton.setGraphic(getChangeReplaceGlyph()); - nextChangeAddressButton.setOnAction(event -> { - EventManager.get().post(new ReplaceChangeAddressEvent(walletTx)); - }); - Tooltip replaceChangeTooltip = new Tooltip("Use next change address"); - nextChangeAddressButton.setTooltip(replaceChangeTooltip); - Label replaceChangeLabel = new Label("", nextChangeAddressButton); - replaceChangeLabel.getStyleClass().add("replace-change-label"); - replaceChangeLabel.setVisible(false); - actionBox.setOnMouseEntered(event -> replaceChangeLabel.setVisible(true)); - actionBox.setOnMouseExited(event -> replaceChangeLabel.setVisible(false)); + if(!isFinal()) { + Button nextChangeAddressButton = new Button(""); + nextChangeAddressButton.setGraphic(getChangeReplaceGlyph()); + nextChangeAddressButton.setOnAction(event -> { + EventManager.get().post(new ReplaceChangeAddressEvent(walletTx)); + }); + Tooltip replaceChangeTooltip = new Tooltip("Use next change address"); + nextChangeAddressButton.setTooltip(replaceChangeTooltip); + Label replaceChangeLabel = new Label("", nextChangeAddressButton); + replaceChangeLabel.getStyleClass().add("replace-change-label"); + replaceChangeLabel.setVisible(false); + actionBox.setOnMouseEntered(event -> replaceChangeLabel.setVisible(true)); + actionBox.setOnMouseExited(event -> replaceChangeLabel.setVisible(false)); + actionBox.getChildren().add(replaceChangeLabel); + } - actionBox.getChildren().addAll(changeLabel, replaceChangeLabel); - outputsBox.getChildren().add(actionBox); + outputNodes.add(new OutputNode(actionBox, changeAddress)); + } + + if(isFinal()) { + Collections.sort(outputNodes); + } + + for(OutputNode outputNode : outputNodes) { + outputsBox.getChildren().add(outputNode.outputLabel); outputsBox.getChildren().add(createSpacer()); } @@ -427,7 +454,7 @@ public class TransactionDiagram extends GridPane { Label feeLabel = highFee ? new Label("High Fee", getWarningGlyph()) : new Label("Fee", getFeeGlyph()); feeLabel.getStyleClass().addAll("output-label", "fee-label"); String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0); - Tooltip feeTooltip = new Tooltip("Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)"); + Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)"); feeTooltip.getStyleClass().add("fee-tooltip"); feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); feeLabel.setTooltip(feeTooltip); @@ -445,7 +472,10 @@ public class TransactionDiagram extends GridPane { String txDesc = "Transaction"; Label txLabel = new Label(txDesc); - Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + String.format("%.2f", walletTx.getTransaction().getVirtualSize()) + " vBytes"); + boolean isFinalized = walletTx.getTransaction().hasScriptSigs() || walletTx.getTransaction().hasWitnesses(); + Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + + String.format("%.2f", walletTx.getTransaction().getVirtualSize()) + " vBytes" + + (walletTx.getFee() < 0 ? "" : "\n" + String.format("%.2f", walletTx.getFee() / walletTx.getTransaction().getVirtualSize()) + " sats/vB" + (isFinalized ? "" : " (non-final)"))); tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); tooltip.getStyleClass().add("transaction-tooltip"); txLabel.setTooltip(tooltip); @@ -469,6 +499,37 @@ public class TransactionDiagram extends GridPane { return spacer; } + private String getOutputLabel(Payment payment) { + if(payment.getLabel() != null) { + return payment.getLabel(); + } + + if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) { + return "Whirlpool Fee"; + } else if(walletTx.isPremixSend(payment)) { + int premixIndex = getOutputIndex(payment.getAddress()) - 2; + return "Premix #" + premixIndex; + } else if(walletTx.isBadbankSend(payment)) { + return "Badbank Change"; + } + + return null; + } + + private int getOutputIndex(Address address) { + return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress())).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow(); + } + + private Wallet getToWallet(Payment payment) { + for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) { + if(openWallet != walletTx.getWallet() && openWallet.isWalletAddress(payment.getAddress())) { + return openWallet; + } + } + + return null; + } + public Glyph getOutputGlyph(Payment payment) { if(payment.getType().equals(Payment.Type.FAKE_MIX)) { return getFakeMixGlyph(); @@ -482,6 +543,8 @@ public class TransactionDiagram extends GridPane { return getWhirlpoolFeeGlyph(); } else if(payment instanceof AdditionalPayment) { return ((AdditionalPayment)payment).getOutputGlyph(this); + } else if(getToWallet(payment) != null) { + return getDepositGlyph(); } return getPaymentGlyph(); @@ -508,6 +571,13 @@ public class TransactionDiagram extends GridPane { return consolidationGlyph; } + public static Glyph getDepositGlyph() { + Glyph depositGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN); + depositGlyph.getStyleClass().add("deposit-icon"); + depositGlyph.setFontSize(12); + return depositGlyph; + } + public static Glyph getPremixGlyph() { Glyph premixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM); premixGlyph.getStyleClass().add("premix-icon"); @@ -589,6 +659,18 @@ public class TransactionDiagram extends GridPane { return lockGlyph; } + public boolean isFinal() { + return finalProperty.get(); + } + + public BooleanProperty finalProperty() { + return finalProperty; + } + + public void setFinal(boolean isFinal) { + this.finalProperty.set(isFinal); + } + private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex { public PayjoinBlockTransactionHashIndex() { super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0); @@ -644,4 +726,23 @@ public class TransactionDiagram extends GridPane { return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n")); } } + + private class OutputNode implements Comparable { + public Node outputLabel; + public Address address; + + public OutputNode(Node outputLabel, Address address) { + this.outputLabel = outputLabel; + this.address = address; + } + + @Override + public int compareTo(TransactionDiagram.OutputNode o) { + try { + return getOutputIndex(address) - getOutputIndex(o.address); + } catch(Exception e) { + return 0; + } + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 4e03946e..3ab836a8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -66,6 +66,9 @@ public class HeadersController extends TransactionFormController implements Init @FXML private IdLabel id; + @FXML + private TransactionDiagram transactionDiagram; + @FXML private Spinner version; @@ -347,6 +350,8 @@ public class HeadersController extends TransactionFormController implements Init updateFee(feeAmt); } + transactionDiagram.update(getWalletTransaction(headersForm.getInputTransactions())); + blockchainForm.managedProperty().bind(blockchainForm.visibleProperty()); signingWalletForm.managedProperty().bind(signingWalletForm.visibleProperty()); @@ -489,6 +494,94 @@ public class HeadersController extends TransactionFormController implements Init feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vB" + (headersForm.isTransactionFinalized() ? "" : " (non-final)")); } + private WalletTransaction getWalletTransaction(Map inputTransactions) { + Wallet wallet = getWalletFromTransactionInputs(); + + if(wallet != null) { + Map selectedTxos = new LinkedHashMap<>(); + Map walletTxos = wallet.getWalletTxos(); + for(TransactionInput txInput : headersForm.getTransaction().getInputs()) { + BlockTransactionHashIndex selectedTxo = walletTxos.keySet().stream().filter(txo -> txInput.getOutpoint().getHash().equals(txo.getHash()) && txInput.getOutpoint().getIndex() == txo.getIndex()) + .findFirst().orElse(new BlockTransactionHashIndex(txInput.getOutpoint().getHash(), 0, null, null, txInput.getOutpoint().getIndex(), 0)); + selectedTxos.put(selectedTxo, walletTxos.get(selectedTxo)); + } + + List payments = new ArrayList<>(); + Map changeMap = new LinkedHashMap<>(); + Map changeOutputScripts = wallet.getWalletOutputScripts(KeyPurpose.CHANGE); + for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) { + WalletNode changeNode = changeOutputScripts.get(txOutput.getScript()); + if(changeNode != null) { + if(headersForm.getTransaction().getOutputs().size() == 4 && headersForm.getTransaction().getOutputs().stream().anyMatch(txo -> txo != txOutput && txo.getValue() == txOutput.getValue())) { + try { + payments.add(new Payment(txOutput.getScript().getToAddresses()[0], ".." + changeNode + " (Fake Mix)", txOutput.getValue(), false, Payment.Type.FAKE_MIX)); + } catch(Exception e) { + //ignore + } + } else { + changeMap.put(changeNode, txOutput.getValue()); + } + } else { + Payment.Type paymentType = Payment.Type.DEFAULT; + Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); + Wallet premixWallet = masterWallet.getChildWallet(StandardAccount.WHIRLPOOL_PREMIX); + if(premixWallet != null && headersForm.getTransaction().getOutputs().stream().anyMatch(premixWallet::isWalletTxo) && txOutput.getIndex() == 1) { + paymentType = Payment.Type.WHIRLPOOL_FEE; + } + + BlockTransactionHashIndex receivedTxo = walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txOutput.getHash()) && txo.getIndex() == txOutput.getIndex()).findFirst().orElse(null); + String label = headersForm.getName() == null || (headersForm.getName().startsWith("[") && headersForm.getName().endsWith("]") && headersForm.getName().length() == 8) ? null : headersForm.getName(); + try { + payments.add(new Payment(txOutput.getScript().getToAddresses()[0], receivedTxo != null ? receivedTxo.getLabel() : label, txOutput.getValue(), false, paymentType)); + } catch(Exception e) { + //ignore + } + } + } + + return new WalletTransaction(wallet, headersForm.getTransaction(), Collections.emptyList(), selectedTxos, payments, changeMap, fee.getValue(), inputTransactions); + } else { + Map selectedTxos = headersForm.getTransaction().getInputs().stream() + .collect(Collectors.toMap(txInput -> { + if(inputTransactions != null) { + BlockTransaction blockTransaction = inputTransactions.get(txInput.getOutpoint().getHash()); + if(blockTransaction != null) { + TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); + return new BlockTransactionHashIndex(blockTransaction.getHash(), blockTransaction.getHeight(), blockTransaction.getDate(), blockTransaction.getFee(), txInput.getOutpoint().getIndex(), txOutput.getValue()); + } + } + return new BlockTransactionHashIndex(txInput.getOutpoint().getHash(), 0, null, null, txInput.getOutpoint().getIndex(), 0); + }, + txInput -> new WalletNode("m/0"), + (u, v) -> { throw new IllegalStateException("Duplicate TXOs"); }, + LinkedHashMap::new)); + selectedTxos.entrySet().forEach(entry -> entry.setValue(null)); + + List payments = new ArrayList<>(); + for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) { + try { + payments.add(new Payment(txOutput.getScript().getToAddresses()[0], null, txOutput.getValue(), false)); + } catch(Exception e) { + //ignore + } + } + + return new WalletTransaction(null, headersForm.getTransaction(), Collections.emptyList(), selectedTxos, payments, Collections.emptyMap(), fee.getValue(), inputTransactions); + } + } + + private Wallet getWalletFromTransactionInputs() { + for(TransactionInput txInput : headersForm.getTransaction().getInputs()) { + for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) { + if(openWallet.isWalletTxo(txInput)) { + return openWallet; + } + } + } + + return null; + } + private void updateBlockchainForm(BlockTransaction blockTransaction, Integer currentHeight) { signaturesForm.setVisible(false); blockchainForm.setVisible(true); @@ -986,6 +1079,7 @@ public class HeadersController extends TransactionFormController implements Init if(feeAmt != null) { updateFee(feeAmt); } + transactionDiagram.update(getWalletTransaction(event.getInputTransactions())); } } @@ -1120,6 +1214,7 @@ public class HeadersController extends TransactionFormController implements Init updateType(); updateSize(); updateFee(headersForm.getPsbt().getFee()); + transactionDiagram.update(getWalletTransaction(headersForm.getInputTransactions())); } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css index d4c5bd07..63635692 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css +++ b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.css @@ -24,6 +24,67 @@ -fx-padding: 0 0 0 12; } +.headers-tabs > .tab-header-area .tab { + -fx-pref-height: 50; + -fx-pref-width: 90; + -fx-alignment: CENTER; +} + +.headers-tabs > .tab-header-area .tab-label { + -fx-pref-height: 50; + -fx-pref-width: 90; + -fx-alignment: CENTER; + -fx-translate-x: -6; +} + +#transactionDiagram .boundary { + -fx-stroke: transparent; +} + +#transactionDiagram .input-label, #transactionDiagram .recipient-label, #transactionDiagram .change-label, #transactionDiagram .fee-tooltip, #transactionDiagram .transaction-tooltip { + -fx-font-size: 13px; + -fx-font-family: 'Roboto Mono'; +} + +#transactionDiagram .fee-warning-icon { + -fx-text-fill: rgb(202, 18, 67); +} + +#transactionDiagram .inputs-type, #transactionDiagram .input-line, #transactionDiagram .output-line { + -fx-fill: transparent; + -fx-text-fill: #696c77; + -fx-stroke: #696c77; + -fx-stroke-width: 1px; +} + +#transactionDiagram .input-dashed-line { + -fx-stroke-dash-array: 5px 5px; +} + +#transactionDiagram .utxo-label .button, #transactionDiagram .replace-change-label .button { + -fx-padding: 0; + -fx-pref-height: 18; + -fx-pref-width: 18; + -fx-border-width: 0; + -fx-background-color: -fx-background; +} + +#transactionDiagram .utxo-label .button .label .text { + -fx-fill: -fx-background; +} + +#transactionDiagram .utxo-label:hover .button .label .text { + -fx-fill: -fx-text-base-color; +} + +#transactionDiagram .change-warning-icon { + -fx-text-fill: rgb(238, 210, 2); +} + +.details-lower .fieldset { + -fx-padding: 0 0 0 0; +} + .future-warning { -fx-text-fill: rgb(238, 210, 2); -fx-padding: 0 0 0 12; diff --git a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml index 52cee8cf..47241480 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/transaction/headers.fxml @@ -25,6 +25,9 @@ + + + @@ -52,83 +55,104 @@ -
-
- - - - - - -
-
+ + + + + + + + + + + + + +
+
+ + + + + + +
+
-
-
- - - - - - - - - - - - - - - - - - - - - - - - -
-
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
- + + + + + + -
-
- - - - - - -
-
+
+
+ + + + + + +
+
-
-
- - - - - - -
-
+
+
+ + + + + + +
+
+
+
+