diff --git a/electrum/gui/icons/paste.png b/electrum/gui/icons/paste.png new file mode 100644 index 000000000..e70bb37f9 Binary files /dev/null and b/electrum/gui/icons/paste.png differ diff --git a/electrum/gui/qml/components/ConfirmPaymentDialog.qml b/electrum/gui/qml/components/ConfirmPaymentDialog.qml new file mode 100644 index 000000000..241c768f1 --- /dev/null +++ b/electrum/gui/qml/components/ConfirmPaymentDialog.qml @@ -0,0 +1,195 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: dialog + + property alias address: finalizer.address + property alias satoshis: finalizer.amount + property string message + + width: parent.width + height: parent.height + + title: qsTr('Confirm Payment') + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + text: qsTr('Amount to send') + } + + RowLayout { + Layout.fillWidth: true + Label { + font.bold: true + text: Config.formatSats(satoshis, false) + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + + Label { + id: fiatValue + Layout.fillWidth: true + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(satoshis, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + font.pixelSize: constants.fontSizeMedium + } + } + + Label { + text: qsTr('Mining fee') + } + + RowLayout { + Label { + id: fee + text: Config.formatSats(finalizer.fee) + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Fee rate') + } + + RowLayout { + Label { + id: feeRate + text: finalizer.feeRate + } + + Label { + text: 'sat/vB' + color: Material.accentColor + } + } + + Label { + text: qsTr('Target') + } + + Label { + id: targetdesc + text: finalizer.target + } + + Slider { + id: feeslider + snapMode: Slider.SnapOnRelease + stepSize: 1 + from: 0 + to: finalizer.sliderSteps + onValueChanged: { + if (activeFocus) + finalizer.sliderPos = value + } + Component.onCompleted: { + value = finalizer.sliderPos + } + Connections { + target: finalizer + function onSliderPosChanged() { + feeslider.value = finalizer.sliderPos + } + } + } + + ComboBox { + id: target + textRole: 'text' + valueRole: 'value' + model: [ + { text: qsTr('ETA'), value: 1 }, + { text: qsTr('Mempool'), value: 2 }, + { text: qsTr('Static'), value: 0 } + ] + onCurrentValueChanged: { + if (activeFocus) + finalizer.method = currentValue + } + Component.onCompleted: { + currentIndex = indexOfValue(finalizer.method) + } + } + + InfoTextArea { + Layout.columnSpan: 2 + visible: finalizer.warning != '' + text: finalizer.warning + iconStyle: InfoTextArea.IconStyle.Warn + } + + CheckBox { + id: final_cb + text: qsTr('Final') + Layout.columnSpan: 2 + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + + Button { + text: qsTr('Cancel') + onClicked: dialog.close() + } + + Button { + text: qsTr('Pay') + enabled: finalizer.valid + onClicked: { + var f_amount = parseFloat(dialog.satoshis) + if (isNaN(f_amount)) + return + var result = Daemon.currentWallet.send_onchain(dialog.address, dialog.satoshis, undefined, false) + } + } + } + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + } + + TxFinalizer { + id: finalizer + wallet: Daemon.currentWallet + onAmountChanged: console.log(amount) + } +} diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 223863d8e..7a81cf085 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -3,10 +3,13 @@ import QtQuick.Controls 2.0 import QtQuick.Layouts 1.0 import QtQuick.Controls.Material 2.0 +import "controls" + Pane { id: rootItem GridLayout { + id: form width: parent.width rowSpacing: constants.paddingSmall columnSpacing: constants.paddingSmall @@ -30,11 +33,29 @@ Pane { placeholderText: qsTr('Paste address or invoice') } - ToolButton { - icon.source: '../../icons/copy.png' - icon.color: 'transparent' - icon.height: constants.iconSizeSmall - icon.width: constants.iconSizeSmall + RowLayout { + spacing: 0 + ToolButton { + icon.source: '../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: address.text = AppController.clipboardToText() + } + ToolButton { + icon.source: '../../icons/qrcode.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + scale: 1.2 + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) + page.onFound.connect(function() { + console.log('got ' + page.invoiceData) + address.text = page.invoiceData['address'] + amount.text = Config.satsToUnits(page.invoiceData['amount']) + description.text = page.invoiceData['message'] + }) + } + } } Label { @@ -71,7 +92,6 @@ Pane { Item { width: 1; height: 1 } - Item { width: 1; height: 1; visible: Daemon.fx.enabled } TextField { @@ -97,54 +117,102 @@ Pane { Item { visible: Daemon.fx.enabled ; height: 1; width: 1 } Label { - text: qsTr('Fee') + text: qsTr('Description') } TextField { - id: fee + id: description font.family: FixedFont - placeholderText: qsTr('sat/vB') - Layout.columnSpan: 2 + placeholderText: qsTr('Description') + Layout.columnSpan: 3 + Layout.fillWidth: true } - Item { width: 1; height: 1 } - RowLayout { Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter spacing: constants.paddingMedium Button { - text: qsTr('Pay') + text: qsTr('Save') + enabled: false + onClicked: { + console.log('TODO: save') + } + } + + Button { + text: qsTr('Pay now') enabled: amount.text != '' && address.text != ''// TODO proper validation onClicked: { var f_amount = parseFloat(amount.text) if (isNaN(f_amount)) return - var sats = Config.unitsToSats(f_amount) - var result = Daemon.currentWallet.send_onchain(address.text, sats, undefined, false) + var sats = Config.unitsToSats(amount.text).toString() + var dialog = confirmPaymentDialog.createObject(app, { + 'address': address.text, + 'satoshis': sats, + 'message': description.text + }) + dialog.open() } } + } + } - Button { - text: qsTr('Scan QR Code') - onClicked: { - var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) - page.onFound.connect(function() { - console.log('got ' + page.invoiceData) - address.text = page.invoiceData['address'] - amount.text = Config.formatSats(page.invoiceData['amount']) - }) + Frame { + verticalPadding: 0 + horizontalPadding: 0 + + anchors { + top: form.bottom + topMargin: constants.paddingXLarge + left: parent.left + right: parent.right + bottom: parent.bottom + } + + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + Item { + Layout.preferredHeight: hitem.height + Layout.preferredWidth: parent.width + Rectangle { + anchors.fill: parent + color: Qt.lighter(Material.background, 1.25) } + RowLayout { + id: hitem + width: parent.width + Label { + text: qsTr('Send queue') + font.pixelSize: constants.fontSizeXLarge + } + } + } + + ListView { + id: listview + Layout.fillHeight: true + Layout.fillWidth: true + clip: true } } } + Component { + id: confirmPaymentDialog + ConfirmPaymentDialog {} + } + Connections { target: Daemon.fx function onQuotesUpdated() { - var a = Config.unitsToSats(amount.text) - amountFiat.text = Daemon.fx.fiatValue(a) + amountFiat.text = Daemon.fx.fiatValue(Config.unitsToSats(amount.text)) } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 671f79afe..6d3dc55ee 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -18,6 +18,7 @@ from .qeqr import QEQRParser, QEQRImageProvider from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin from .qefx import QEFX +from .qetxfinalizer import QETxFinalizer notification = None @@ -92,6 +93,10 @@ class QEAppController(QObject): def textToClipboard(self, text): QGuiApplication.clipboard().setText(text) + @pyqtSlot(result='QString') + def clipboardToText(self): + return QGuiApplication.clipboard().text() + class ElectrumQmlApplication(QGuiApplication): _valid = True @@ -109,6 +114,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin') qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser') qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') + qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py new file mode 100644 index 000000000..c1bb3bad2 --- /dev/null +++ b/electrum/gui/qml/qetxfinalizer.py @@ -0,0 +1,228 @@ +from decimal import Decimal + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.transaction import PartialTxOutput +from electrum.util import NotEnoughFunds, profiler + +from .qewallet import QEWallet + +class QETxFinalizer(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _address = '' + _amount = '' + _fee = '' + _feeRate = '' + _wallet = None + _valid = False + _sliderSteps = 0 + _sliderPos = 0 + _method = -1 + _warning = '' + _target = '' + config = None + + validChanged = pyqtSignal() + @pyqtProperty(bool, notify=validChanged) + def valid(self): + return self._valid + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.config = self._wallet.wallet.config + self.read_config() + self.walletChanged.emit() + + addressChanged = pyqtSignal() + @pyqtProperty(str, notify=addressChanged) + def address(self): + return self._address + + @address.setter + def address(self, address): + if self._address != address: + self._address = address + self.addressChanged.emit() + + amountChanged = pyqtSignal() + @pyqtProperty(str, notify=amountChanged) + def amount(self): + return self._amount + + @amount.setter + def amount(self, amount): + if self._amount != amount: + self._logger.info('amount = "%s"' % amount) + self._amount = amount + self.amountChanged.emit() + + feeChanged = pyqtSignal() + @pyqtProperty(str, notify=feeChanged) + def fee(self): + return self._fee + + @fee.setter + def fee(self, fee): + if self._fee != fee: + self._fee = fee + self.feeChanged.emit() + + feeRateChanged = pyqtSignal() + @pyqtProperty(str, notify=feeRateChanged) + def feeRate(self): + return self._feeRate + + @feeRate.setter + def feeRate(self, feeRate): + if self._feeRate != feeRate: + self._feeRate = feeRate + self.feeRateChanged.emit() + + targetChanged = pyqtSignal() + @pyqtProperty(str, notify=targetChanged) + def target(self): + return self._target + + @target.setter + def target(self, target): + if self._target != target: + self._target = target + self.targetChanged.emit() + + warningChanged = pyqtSignal() + @pyqtProperty(str, notify=warningChanged) + def warning(self): + return self._warning + + @warning.setter + def warning(self, warning): + if self._warning != warning: + self._warning = warning + self.warningChanged.emit() + + sliderStepsChanged = pyqtSignal() + @pyqtProperty(int, notify=sliderStepsChanged) + def sliderSteps(self): + return self._sliderSteps + + sliderPosChanged = pyqtSignal() + @pyqtProperty(int, notify=sliderPosChanged) + def sliderPos(self): + return self._sliderPos + + @sliderPos.setter + def sliderPos(self, sliderPos): + if self._sliderPos != sliderPos: + self._sliderPos = sliderPos + self.save_config() + self.sliderPosChanged.emit() + + methodChanged = pyqtSignal() + @pyqtProperty(int, notify=methodChanged) + def method(self): + return self._method + + @method.setter + def method(self, method): + if self._method != method: + self._method = method + self.update_slider() + self.methodChanged.emit() + self.save_config() + + def get_method(self): + dynfees = self._method > 0 + mempool = self._method == 2 + return dynfees, mempool + + def update_slider(self): + dynfees, mempool = self.get_method() + maxp, pos, fee_rate = self.config.get_fee_slider(dynfees, mempool) + self._sliderSteps = maxp + self._sliderPos = pos + self.sliderStepsChanged.emit() + self.sliderPosChanged.emit() + + def read_config(self): + mempool = self.config.use_mempool_fees() + dynfees = self.config.is_dynfee() + self._method = (2 if mempool else 1) if dynfees else 0 + self.update_slider() + self.methodChanged.emit() + self.update(False) + + def save_config(self): + value = int(self._sliderPos) + dynfees, mempool = self.get_method() + self.config.set_key('dynamic_fees', dynfees, False) + self.config.set_key('mempool_fees', mempool, False) + if dynfees: + if mempool: + self.config.set_key('depth_level', value, True) + else: + self.config.set_key('fee_level', value, True) + else: + self.config.set_key('fee_per_kb', self.config.static_fee(value), True) + self.update(False) + + @profiler + def make_tx(self, rbf: bool): + coins = self._wallet.wallet.get_spendable_coins(None) + outputs = [PartialTxOutput.from_address_and_value(self.address, int(self.amount))] + tx = self._wallet.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None) + self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) + return tx + + @pyqtSlot(bool) + def update(self, rbf): + #rbf = not bool(self.ids.final_cb.active) if self.show_final else False + try: + # make unsigned transaction + tx = self.make_tx(rbf) + except NotEnoughFunds: + self.warning = _("Not enough funds") + self._valid = False + self.validChanged.emit() + return + except Exception as e: + self._logger.error(str(e)) + self.warning = repr(e) + self._valid = False + self.validChanged.emit() + return + + amount = int(self.amount) if self.amount != '!' else tx.output_value() + tx_size = tx.estimated_size() + fee = tx.get_fee() + feerate = Decimal(fee) / tx_size # sat/byte + + self.fee = str(fee) + self.feeRate = f'{feerate:.1f}' + + #x_fee = run_hook('get_tx_extra_fee', self._wallet.wallet, tx) + fee_warning_tuple = self._wallet.wallet.get_tx_fee_warning( + invoice_amt=amount, tx_size=tx_size, fee=fee) + if fee_warning_tuple: + allow_send, long_warning, short_warning = fee_warning_tuple + self.warning = long_warning + else: + self.warning = '' + + target, tooltip, dyn = self.config.get_fee_target() + self.target = target + + self._valid = True + self.validChanged.emit()