diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 174428e58..168b37702 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -9,24 +9,196 @@ Pane { id: rootItem visible: Daemon.currentWallet !== undefined - ColumnLayout { + GridLayout { + id: form width: parent.width - spacing: 20 + rowSpacing: 10 + columnSpacing: 10 + columns: 3 - Image { - id: img + Label { + text: qsTr('Message') } TextField { - id: text + id: message + onTextChanged: img.source = 'image://qrgen/' + text + Layout.columnSpan: 2 + Layout.fillWidth: true + } + + Label { + text: qsTr('Requested Amount') + wrapMode: Text.WordWrap + Layout.preferredWidth: 50 // trigger wordwrap + } + + TextField { + id: amount + } + + Item { + Layout.rowSpan: 3 + width: img.width + height: img.height + + Image { + id: img + cache: false + anchors { + top: parent.top + left: parent.left + } + source: 'image://qrgen/test' + } + } + + Label { + text: qsTr('Expires after') + Layout.fillWidth: false + } + + ComboBox { + id: expires + textRole: 'text' + valueRole: 'value' + model: ListModel { + id: expiresmodel + Component.onCompleted: { + // we need to fill the model like this, as ListElement can't evaluate script + expiresmodel.append({'text': qsTr('Never'), 'value': 0}) + expiresmodel.append({'text': qsTr('10 minutes'), 'value': 10*60}) + expiresmodel.append({'text': qsTr('1 hour'), 'value': 60*60}) + expiresmodel.append({'text': qsTr('1 day'), 'value': 24*60*60}) + expiresmodel.append({'text': qsTr('1 week'), 'value': 7*24*60*60}) + expires.currentIndex = 0 + } + } } Button { - text: 'generate' + Layout.columnSpan: 2 + text: qsTr('Create Request') onClicked: { - img.source = 'image://qrgen/' + text.text + var a = parseFloat(amount.text) + Daemon.currentWallet.create_invoice(a, message.text, expires.currentValue) } } } + Frame { + clip: true + verticalPadding: 0 + horizontalPadding: 0 + + anchors { + top: form.bottom + topMargin: constants.paddingXLarge + left: parent.left + right: parent.right + bottom: parent.bottom + } + + background: Rectangle { + color: Qt.darker(Material.background, 1.25) + } + + ListView { + anchors.fill: parent + + model: Daemon.currentWallet.requestModel + headerPositioning: ListView.OverlayHeader + + header: Item { + z: 1 + height: hitem.height + width: ListView.view.width + Rectangle { + anchors.fill: hitem + color: Qt.lighter(Material.background, 1.25) + } + RowLayout { + id: hitem + width: parent.width + Label { + text: qsTr('Receive queue') + font.pixelSize: constants.fontSizeXLarge + } + } + } + + delegate: Item { + z: -1 + height: item.height + width: ListView.view.width + GridLayout { + id: item + columns: 5 + Image { + Layout.rowSpan: 2 + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + source: model.type == 0 ? "../../icons/bitcoin.png" : "../../icons/lightning.png" + } + Label { + Layout.fillWidth: true + Layout.columnSpan: 2 + text: model.message + font.pixelSize: constants.fontSizeLarge + } + + Label { + text: qsTr('Amount: ') + font.pixelSize: constants.fontSizeSmall + } + Label { + text: model.amount + font.pixelSize: constants.fontSizeSmall + } + + Label { + text: qsTr('Timestamp: ') + font.pixelSize: constants.fontSizeSmall + } + Label { + text: model.timestamp + font.pixelSize: constants.fontSizeSmall + } + + Label { + text: qsTr('Status: ') + font.pixelSize: constants.fontSizeSmall + } + Label { + text: model.status + font.pixelSize: constants.fontSizeSmall + } + } + + } + + add: Transition { + NumberAnimation { properties: 'y'; from: -50; duration: 300 } + NumberAnimation { properties: 'opacity'; from: 0; to: 1.0; duration: 700 } + } + addDisplaced: Transition { + NumberAnimation { properties: 'y'; duration: 100 } + } + + } + } + + Connections { + target: Daemon.currentWallet + function onRequestCreateSuccess() { + message.text = '' + amount.text = '' + } + function onRequestCreateError(error) { + console.log(error) + var dialog = app.messageDialog.createObject(app, {'text': error}) + dialog.open() + } + } + } diff --git a/electrum/gui/qml/qerequestlistmodel.py b/electrum/gui/qml/qerequestlistmodel.py new file mode 100644 index 000000000..667b93865 --- /dev/null +++ b/electrum/gui/qml/qerequestlistmodel.py @@ -0,0 +1,76 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.logging import get_logger +from electrum.util import Satoshis, format_time +from electrum.invoices import Invoice + +class QERequestListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.requests = [] + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('type','timestamp','message','amount','status') + _ROLE_KEYS = range(Qt.UserRole + 1, Qt.UserRole + 1 + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + + def rowCount(self, index): + return len(self.requests) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + request = self.requests[index.row()] + role_index = role - (Qt.UserRole + 1) + value = request[self._ROLE_NAMES[role_index]] + if isinstance(value, bool) or isinstance(value, list) or isinstance(value, int) or value is None: + return value + if isinstance(value, Satoshis): + return value.value + return str(value) + + def clear(self): + self.beginResetModel() + self.requests = [] + self.endResetModel() + + def request_to_model(self, req: Invoice): + item = {} + key = self.wallet.get_key_for_receive_request(req) # (verified) address for onchain, rhash for LN + status = self.wallet.get_request_status(key) + item['status'] = req.get_status_str(status) + item['type'] = req.type # 0=onchain, 2=LN + timestamp = req.time + item['timestamp'] = format_time(timestamp) + item['amount'] = req.get_amount_sat() + item['message'] = req.message + + #amount_str = self.parent.format_amount(amount) if amount else "" + + return item + + @pyqtSlot() + def init_model(self): + requests = [] + for req in self.wallet.get_unpaid_requests(): + item = self.request_to_model(req) + self._logger.debug(str(item)) + requests.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, len(self.requests) - 1) + self.requests = requests + self.endInsertRows() + + def add_request(self, request: Invoice): + item = self.request_to_model(request) + self._logger.debug(str(item)) + + self.beginInsertRows(QModelIndex(), 0, 0) + self.requests.insert(0, item) + self.endInsertRows() diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 8682c95f5..214b4df03 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,12 +1,18 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex, QByteArray -from electrum.util import register_callback, Satoshis +from typing import Optional, TYPE_CHECKING, Sequence, List, Union + +from electrum.i18n import _ +from electrum.util import register_callback, Satoshis, format_time from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum import bitcoin from electrum.transaction import Transaction, tx_from_any, PartialTransaction, PartialTxOutput -from electrum.invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED, PR_TYPE_ONCHAIN, PR_TYPE_LN +from electrum.invoices import (Invoice, InvoiceError, PR_TYPE_ONCHAIN, PR_TYPE_LN, + PR_DEFAULT_EXPIRATION_WHEN_CREATING, PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED, PR_TYPE_ONCHAIN, PR_TYPE_LN) + +from .qerequestlistmodel import QERequestListModel class QETransactionListModel(QAbstractListModel): def __init__(self, wallet, parent=None): @@ -130,7 +136,11 @@ class QEWallet(QObject): self.wallet = wallet self._historyModel = QETransactionListModel(wallet) self._addressModel = QEAddressListModel(wallet) + self._requestModel = QERequestListModel(wallet) + self._historyModel.init_model() + self._requestModel.init_model() + register_callback(self.on_request_status, ['request_status']) register_callback(self.on_status, ['status']) @@ -138,6 +148,9 @@ class QEWallet(QObject): dataChanged = pyqtSignal() # dummy to silence warnings + requestCreateSuccess = pyqtSignal() + requestCreateError = pyqtSignal([str], arguments=['error']) + requestStatus = pyqtSignal() def on_request_status(self, event, *args): self._logger.debug(str(event)) @@ -153,6 +166,11 @@ class QEWallet(QObject): def addressModel(self): return self._addressModel + requestModelChanged = pyqtSignal() + @pyqtProperty(QERequestListModel, notify=requestModelChanged) + def requestModel(self): + return self._requestModel + @pyqtProperty('QString', notify=dataChanged) def txinType(self): return self.wallet.get_txin_type(self.wallet.dummy_address()) @@ -218,3 +236,65 @@ class QEWallet(QObject): outputs = [PartialTxOutput.from_address_and_value(address, amount)] tx = self.wallet.make_unsigned_transaction(coins=coins,outputs=outputs) return True + + def create_bitcoin_request(self, amount: int, message: str, expiration: int) -> Optional[str]: + addr = self.wallet.get_unused_address() + if addr is None: + # TODO implement + return + #if not self.wallet.is_deterministic(): # imported wallet + #msg = [ + #_('No more addresses in your wallet.'), ' ', + #_('You are using a non-deterministic wallet, which cannot create new addresses.'), ' ', + #_('If you want to create new addresses, use a deterministic wallet instead.'), '\n\n', + #_('Creating a new payment request will reuse one of your addresses and overwrite an existing request. Continue anyway?'), + #] + #if not self.question(''.join(msg)): + #return + #addr = self.wallet.get_receiving_address() + #else: # deterministic wallet + #if not self.question(_("Warning: The next address will not be recovered automatically if you restore your wallet from seed; you may need to add it manually.\n\nThis occurs because you have too many unused addresses in your wallet. To avoid this situation, use the existing addresses first.\n\nCreate anyway?")): + #return + #addr = self.wallet.create_new_address(False) + req = self.wallet.make_payment_request(addr, amount, message, expiration) + try: + self.wallet.add_payment_request(req) + except Exception as e: + self.logger.exception('Error adding payment request') + self.requestCreateError.emit(_('Error adding payment request') + ':\n' + repr(e)) + else: + # TODO: check this flow. Only if alias is defined in config. OpenAlias? + pass + #self.sign_payment_request(addr) + self._requestModel.add_request(req) + return addr + + @pyqtSlot(int, 'QString', int) + def create_invoice(self, amount: int, message: str, expiration: int, is_lightning: bool = False): + expiry = expiration #TODO: self.config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING) + try: + if is_lightning: + if not self.wallet.lnworker.channels: + #self.show_error(_("You need to open a Lightning channel first.")) + self.requestCreateError.emit(_("You need to open a Lightning channel first.")) + return + # TODO maybe show a warning if amount exceeds lnworker.num_sats_can_receive (as in kivy) + key = self.wallet.lnworker.add_request(amount, message, expiry) + else: + key = self.create_bitcoin_request(amount, message, expiry) + if not key: + return + #self.address_list.update() + self._addressModel.init_model() + except InvoiceError as e: + self.requestCreateError.emit(_('Error creating payment request') + ':\n' + str(e)) + return + + assert key is not None + self.requestCreateSuccess.emit() + + # TODO:copy to clipboard + #r = self.wallet.get_request(key) + #content = r.invoice if r.is_lightning() else r.get_address() + #title = _('Invoice') if is_lightning else _('Address') + #self.do_copy(content, title=title)