From 49a67a5cd29484c461a77e9ac357bdf18b517f00 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sat, 5 Mar 2022 09:07:45 +0100 Subject: [PATCH 001/218] build android apk with qt5 update requirements file for building PyQt5 --- contrib/android/Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contrib/android/Makefile b/contrib/android/Makefile index 93d426877..f9c11491f 100644 --- a/contrib/android/Makefile +++ b/contrib/android/Makefile @@ -25,9 +25,10 @@ export BUILD_TIME := $(shell LC_ALL=C TZ=UTC date +'%H:%M:%S' -d @$(SOUR theming: #bash -c 'for i in network lightning; do convert -background none theming/light/$i.{svg,png}; done' - $(PYTHON) -m kivy.atlas ../../electrum/gui/kivy/theming/atlas/light 1024 ../../electrum/gui/kivy/theming/light/*.png + #$(PYTHON) -m kivy.atlas ../../electrum/gui/kivy/theming/atlas/light 1024 ../../electrum/gui/kivy/theming/light/*.png prepare: # running pre build setup + @cp buildozer_qml.spec ../../buildozer.spec # copy electrum to main.py @cp buildozer_$(ELEC_APK_GUI).spec ../../buildozer.spec @cp ../../run_electrum ../../main.py From 1df5187719b80eebbc504bd2bdfbffeb1e49a97b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 1 Apr 2021 14:30:51 +0200 Subject: [PATCH 002/218] qml: add 'qml' gui option and add gui.qml.ELectrumGui to type hint for gui_object --- electrum/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/commands.py b/electrum/commands.py index 526611513..c952b0221 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1502,7 +1502,7 @@ def get_parser(): # gui parser_gui = subparsers.add_parser('gui', description="Run Electrum's Graphical User Interface.", help="Run GUI (default)") parser_gui.add_argument("url", nargs='?', default=None, help="bitcoin URI (or bip70 file)") - parser_gui.add_argument("-g", "--gui", dest="gui", help="select graphical user interface", choices=['qt', 'kivy', 'text', 'stdio']) + parser_gui.add_argument("-g", "--gui", dest="gui", help="select graphical user interface", choices=['qt', 'kivy', 'text', 'stdio', 'qml']) parser_gui.add_argument("-m", action="store_true", dest="hide_gui", default=False, help="hide GUI on startup") parser_gui.add_argument("-L", "--lang", dest="language", default=None, help="default language used in GUI") parser_gui.add_argument("--daemon", action="store_true", dest="daemon", default=False, help="keep daemon running after GUI is closed") From 7eb733757ac47e43798a1198b1424101039d8d84 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 1 Apr 2021 14:32:43 +0200 Subject: [PATCH 003/218] qml: add initial qml.ElectrumGui class, Electrum QObject wrappers and an initial QObject for QR en/decoding --- electrum/gui/qml/__init__.py | 100 ++++++++++++++++++++++++++ electrum/gui/qml/qenetwork.py | 128 ++++++++++++++++++++++++++++++++++ electrum/gui/qml/qeqr.py | 58 +++++++++++++++ electrum/gui/qml/qewallet.py | 43 ++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 electrum/gui/qml/__init__.py create mode 100644 electrum/gui/qml/qenetwork.py create mode 100644 electrum/gui/qml/qeqr.py create mode 100644 electrum/gui/qml/qewallet.py diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py new file mode 100644 index 000000000..581ff0bda --- /dev/null +++ b/electrum/gui/qml/__init__.py @@ -0,0 +1,100 @@ +import os +import signal +import sys +import traceback +import threading +from typing import Optional, TYPE_CHECKING, List + +try: + import PyQt5 +except Exception: + sys.exit("Error: Could not import PyQt5 on Linux systems, you may try 'sudo apt-get install python3-pyqt5'") + +try: + import PyQt5.QtQml +except Exception: + sys.exit("Error: Could not import PyQt5.QtQml on Linux systems, you may try 'sudo apt-get install python3-pyqt5.qtquick'") + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QUrl +from PyQt5.QtGui import QGuiApplication +from PyQt5.QtQml import qmlRegisterType, QQmlComponent, QQmlApplicationEngine +from PyQt5.QtQuick import QQuickView +import PyQt5.QtCore as QtCore +import PyQt5.QtQml as QtQml + +from electrum.i18n import _, set_language +from electrum.plugin import run_hook +from electrum.base_wizard import GoBack +from electrum.util import (UserCancelled, profiler, + WalletFileException, BitcoinException, get_new_wallet_name) +from electrum.wallet import Wallet, Abstract_Wallet +from electrum.wallet_db import WalletDB +from electrum.logging import Logger + +if TYPE_CHECKING: + from electrum.daemon import Daemon + from electrum.simple_config import SimpleConfig + from electrum.plugin import Plugins + +from .qenetwork import QENetwork, QEDaemon, QEWalletListModel +from .qewallet import * +from .qeqr import QEQR + +class ElectrumQmlApplication(QGuiApplication): + def __init__(self, args, daemon): + super().__init__(args) + + qmlRegisterType(QEWalletListModel, 'QElectrum', 1, 0, 'QEWalletListModel') + qmlRegisterType(QEWallet, 'QElectrum', 1, 0, 'QEWallet') + + self.engine = QQmlApplicationEngine(parent=self) + self.context = self.engine.rootContext() + self.qen = QENetwork(daemon.network) + self.context.setContextProperty('Network', self.qen) + self.qed = QEDaemon(daemon) + self.context.setContextProperty('Daemon', self.qed) + self.qeqr = QEQR() + self.context.setContextProperty('QR', self.qeqr) + self.engine.load(QUrl('electrum/gui/qml/components/main.qml')) + +class ElectrumGui(Logger): + + @profiler + def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): + # TODO set_language(config.get('language', get_default_language())) + Logger.__init__(self) + self.logger.info(f"Qml GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}") + # Uncomment this call to verify objects are being properly + # GC-ed when windows are closed + #network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer, + # ElectrumWindow], interval=5)]) + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_X11InitThreads) + if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"): + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) +# if hasattr(QGuiApplication, 'setDesktopFileName'): +# QGuiApplication.setDesktopFileName('electrum.desktop') + self.gui_thread = threading.current_thread() + self.config = config + self.daemon = daemon + self.plugins = plugins + self.app = ElectrumQmlApplication(sys.argv, self.daemon) + + # TODO when plugin support. run_hook('init_qml', self) + + def close(self): +# for window in self.windows: +# window.close() +# if self.network_dialog: +# self.network_dialog.close() +# if self.lightning_dialog: +# self.lightning_dialog.close() +# if self.watchtower_dialog: +# self.watchtower_dialog.close() + self.app.quit() + + def main(self): + self.app.exec_() + + def stop(self): + self.logger.info('closing GUI') + self.app.quit() diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py new file mode 100644 index 000000000..312021d9c --- /dev/null +++ b/electrum/gui/qml/qenetwork.py @@ -0,0 +1,128 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.util import register_callback +from electrum.logging import get_logger +from electrum.wallet import Wallet, Abstract_Wallet + +from .qewallet import QEWallet + +class QENetwork(QObject): + def __init__(self, network, parent=None): + super().__init__(parent) + self.network = network + register_callback(self.on_network_updated, ['network_updated']) + register_callback(self.on_blockchain_updated, ['blockchain_updated']) + register_callback(self.on_default_server_changed, ['default_server_changed']) + register_callback(self.on_proxy_set, ['proxy_set']) + register_callback(self.on_status, ['status']) + + _logger = get_logger(__name__) + + network_updated = pyqtSignal() + blockchain_updated = pyqtSignal() + default_server_changed = pyqtSignal() + proxy_set = pyqtSignal() + status_updated = pyqtSignal() + + _num_updates = 0 + _server = "" + _height = 0 + _status = "" + + def on_network_updated(self, event, *args): + self._num_updates = self._num_updates + 1 + self.network_updated.emit() + + def on_blockchain_updated(self, event, *args): + self._logger.info('chainupdate: ' + str(event) + str(args)) + self._height = self.network.get_local_height() + self.blockchain_updated.emit() + + def on_default_server_changed(self, event, *args): + netparams = self.network.get_parameters() + self._server = str(netparams.server) + self.default_server_changed.emit() + + def on_proxy_set(self, event, *args): + self._logger.info('proxy set') + self.proxy_set.emit() + + def on_status(self, event, *args): + self._logger.info('status updated') + self._status = self.network.connection_status + self.status_updated.emit() + + @pyqtProperty(int,notify=network_updated) + def updates(self): + return self._num_updates + + @pyqtProperty(int,notify=blockchain_updated) + def height(self): + return self._height + + @pyqtProperty('QString',notify=default_server_changed) + def server(self): + return self._server + + @pyqtProperty('QString',notify=status_updated) + def status(self): + return self._status + +class QEWalletListModel(QAbstractListModel): + def __init__(self, parent=None): + QAbstractListModel.__init__(self, parent) + self.wallets = [] + + def rowCount(self, index): + return len(self.wallets) + + def data(self, index, role): + if role == Qt.DisplayRole: + return self.wallets[index.row()].basename() + + def add_wallet(self, wallet: Abstract_Wallet = None): + if wallet == None: + return + self.beginInsertRows(QModelIndex(), len(self.wallets), len(self.wallets)); + self.wallets.append(wallet); + self.endInsertRows(); + + +class QEDaemon(QObject): + def __init__(self, daemon, parent=None): + super().__init__(parent) + self.daemon = daemon + + _logger = get_logger(__name__) + _wallet = '' + _loaded_wallets = QEWalletListModel() + + wallet_loaded = pyqtSignal() + + @pyqtSlot() + def load_wallet(self, path=None, password=None): + self._logger.info(str(self.daemon.get_wallets())) + if path == None: + path = self.daemon.config.get('recently_open')[0] + wallet = self.daemon.load_wallet(path, password) + if wallet != None: + self._loaded_wallets.add_wallet(wallet) + self._wallet = wallet.basename() + self._current_wallet = QEWallet(wallet) + self.wallet_loaded.emit() + self._logger.info(str(self.daemon.get_wallets())) + else: + self._logger.info('fail open wallet') + + @pyqtProperty('QString',notify=wallet_loaded) + def walletName(self): + return self._wallet + + @pyqtProperty(QEWalletListModel) + def activeWallets(self): + return self._loaded_wallets + + @pyqtProperty(QEWallet,notify=wallet_loaded) + def currentWallet(self): + return self._current_wallet diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py new file mode 100644 index 000000000..0df046869 --- /dev/null +++ b/electrum/gui/qml/qeqr.py @@ -0,0 +1,58 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl + +from electrum.logging import get_logger + +from PIL import Image +from ctypes import * + +class QEQR(QObject): + def __init__(self, text=None, parent=None): + super().__init__(parent) + self._text = text + + _logger = get_logger(__name__) + scan_ready_changed = pyqtSignal() + + _ready = True + + @pyqtSlot('QImage') + def scanImage(self, image=None): + if not self._ready: + self._logger.warning("Already processing an image. Check 'ready' property before calling scanImage") + return + self._ready = False + self.scan_ready_changed.emit() + + pilimage = self.convertToPILImage(image) + parseQR(pilimage) + + self._ready = True + + def logImageStats(self, image): + self._logger.info('width: ' + str(image.width())) + self._logger.info('height: ' + str(image.height())) + self._logger.info('depth: ' + str(image.depth())) + self._logger.info('format: ' + str(image.format())) + + def convertToPILImage(self, image) -> Image: + self.logImageStats(image) + + rawimage = image.constBits() + # assumption: pixels are 32 bits ARGB + numbytes = image.width() * image.height() * 4 + + self._logger.info(type(rawimage)) + buf = bytearray(numbytes) + c_buf = (c_byte * numbytes).from_buffer(buf) + memmove(c_buf, c_void_p(rawimage.__int__()), numbytes) + buf2 = bytes(buf) + + return Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') + + def parseQR(self, image): + pass + + @pyqtProperty(bool, notify=scan_ready_changed) + def ready(self): + return self._ready + diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py new file mode 100644 index 000000000..58db1b60d --- /dev/null +++ b/electrum/gui/qml/qewallet.py @@ -0,0 +1,43 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.util import register_callback +from electrum.logging import get_logger +from electrum.wallet import Wallet, Abstract_Wallet + +class QETransactionsListModel(QAbstractListModel): + def __init__(self, parent=None): + super().__init__(parent) + self.tx_history = [] + + def rowCount(self, index): + return len(self.tx_history) + + def data(self, index, role): + if role == Qt.DisplayRole: + return str(self.tx_history[index.row()]['bc_value']) + + def set_history(self, history): + self.beginInsertRows(QModelIndex(), 0, len(history) - 1) + self.tx_history = history + self.endInsertRows() + +class QEWallet(QObject): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.get_history() + + _logger = get_logger(__name__) + + _historyModel = QETransactionsListModel() + + @pyqtProperty(QETransactionsListModel) + def historyModel(self): + return self._historyModel + + def get_history(self): + history = self.wallet.get_detailed_history(show_addresses = True) + txs = history['transactions'] + self._historyModel.set_history(txs) + From 3dce09328e5656516c2ac90ec3c7a1398a220a84 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 1 Apr 2021 19:54:53 +0200 Subject: [PATCH 004/218] qml: Initial QML to test QObject property binding, ListModels, Camera --- electrum/gui/qml/components/EButton.qml | 41 +++++++++++++++++ electrum/gui/qml/components/landing.qml | 60 +++++++++++++++++++++++++ electrum/gui/qml/components/main.qml | 35 +++++++++++++++ electrum/gui/qml/components/scan.qml | 58 ++++++++++++++++++++++++ electrum/gui/qml/components/splash.qml | 14 ++++++ electrum/gui/qml/components/tx.qml | 39 ++++++++++++++++ 6 files changed, 247 insertions(+) create mode 100644 electrum/gui/qml/components/EButton.qml create mode 100644 electrum/gui/qml/components/landing.qml create mode 100644 electrum/gui/qml/components/main.qml create mode 100644 electrum/gui/qml/components/scan.qml create mode 100644 electrum/gui/qml/components/splash.qml create mode 100644 electrum/gui/qml/components/tx.qml diff --git a/electrum/gui/qml/components/EButton.qml b/electrum/gui/qml/components/EButton.qml new file mode 100644 index 000000000..87f7d459c --- /dev/null +++ b/electrum/gui/qml/components/EButton.qml @@ -0,0 +1,41 @@ +import QtQuick 2.6 + +Item { + id: rootItem + width: visbut.width + 10 + height: visbut.height + 10 + + signal clicked + property string text + + Rectangle { + id: visbut + border { + color: '#444444' + width: 2 + } + color: '#dddddd' + radius: 4 + + anchors.centerIn: parent + width: buttonText.width + height: buttonText.height + + MouseArea { + anchors.fill: parent + onClicked: rootItem.clicked() + } + } + + Text { + id: buttonText + leftPadding: 30 + rightPadding: 30 + topPadding: 20 + bottomPadding: 20 + verticalAlignment: Text.AlignVCenter + text: rootItem.text + color: 'red' + } + +} diff --git a/electrum/gui/qml/components/landing.qml b/electrum/gui/qml/components/landing.qml new file mode 100644 index 000000000..469799937 --- /dev/null +++ b/electrum/gui/qml/components/landing.qml @@ -0,0 +1,60 @@ +import QtQuick 2.6 +import QtQuick.Controls 1.4 +import QtQml 2.6 + +Item { + Column { + width: parent.width + + Row { + Text { text: "Server: " } + Text { text: Network.server } + } + Row { + Text { text: "Local Height: " } + Text { text: Network.height } + } + Row { + Text { text: "Status: " } + Text { text: Network.status } + } + Row { + Text { text: "Wallet: " } + Text { text: Daemon.walletName } + } + + EButton { + text: 'Scan QR Code' + onClicked: app.stack.push(Qt.resolvedUrl('scan.qml')) + } + + EButton { + text: 'Show TXen' + onClicked: app.stack.push(Qt.resolvedUrl('tx.qml')) + } + + ListView { + width: parent.width + height: 200 + model: Daemon.activeWallets + delegate: Item { + width: parent.width + + Row { + Rectangle { + width: 10 + height: parent.height + color: 'red' + } + Text { + leftPadding: 20 + text: model.display + } + } + } + } + + } + +} + diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml new file mode 100644 index 000000000..233c671f3 --- /dev/null +++ b/electrum/gui/qml/components/main.qml @@ -0,0 +1,35 @@ +import QtQuick 2.6 +import QtQuick.Controls 1.4 +import QtQml 2.6 +import QtMultimedia 5.6 + +ApplicationWindow +{ + id: app + visible: true + width: 480 + height: 800 + color: '#dddddd' + + property alias stack: mainStackView + + StackView { + id: mainStackView + anchors.fill: parent + + initialItem: Qt.resolvedUrl('splash.qml') + } + + Timer { + id: splashTimer + interval: 400 + onTriggered: { + mainStackView.push(Qt.resolvedUrl('landing.qml')) + } + } + + Component.onCompleted: { + Daemon.load_wallet() + splashTimer.start() + } +} diff --git a/electrum/gui/qml/components/scan.qml b/electrum/gui/qml/components/scan.qml new file mode 100644 index 000000000..a13b04615 --- /dev/null +++ b/electrum/gui/qml/components/scan.qml @@ -0,0 +1,58 @@ +import QtQuick 2.6 +import QtMultimedia 5.6 + + +Item { + Column { + width: parent.width + + Item { + id: voc + width: parent.width + height: parent.width + + VideoOutput { + id: vo + anchors.fill: parent + source: camera + //fillMode: VideoOutput.PreserveAspectCrop + } + + MouseArea { + anchors.fill: parent + onClicked: { + vo.grabToImage(function(result) { + console.log("grab: image=" + (result.image !== undefined) + " url=" + result.url) + if (result.image !== undefined) { + console.log('scanning image for QR') + QR.scanImage(result.image) + } + }) + } + } + } + + EButton { + text: 'Exit' + onClicked: app.stack.pop() + } + } + + Camera { + id: camera + deviceId: QtMultimedia.defaultCamera.deviceId + viewfinder.resolution: "640x480" + + function dumpstats() { + console.log(camera.viewfinder.resolution) + console.log(camera.viewfinder.minimumFrameRate) + console.log(camera.viewfinder.maximumFrameRate) + var resolutions = camera.supportedViewfinderResolutions() + resolutions.forEach(function(item, i) { + console.log('' + item.width + 'x' + item.height) + }) + } + } + + +} diff --git a/electrum/gui/qml/components/splash.qml b/electrum/gui/qml/components/splash.qml new file mode 100644 index 000000000..cd882d712 --- /dev/null +++ b/electrum/gui/qml/components/splash.qml @@ -0,0 +1,14 @@ +import QtQuick 2.0 + +Item { + Rectangle { + anchors.fill: parent + color: '#111111' + } + + Image { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + source: "../../icons/electrum.png" + } +} diff --git a/electrum/gui/qml/components/tx.qml b/electrum/gui/qml/components/tx.qml new file mode 100644 index 000000000..116b18d25 --- /dev/null +++ b/electrum/gui/qml/components/tx.qml @@ -0,0 +1,39 @@ +import QtQuick 2.6 + +Item { + id: rootItem +// height: 800 + Column { + width: parent.width +// height: parent.height + + Text { + text: "Transactions" + } + + ListView { + width: parent.width + height: 200 +// anchors.bottom: rootItem.bottom + + model: Daemon.currentWallet.historyModel + delegate: Item { + width: parent.width + height: line.height + Row { + id: line + Rectangle { + width: 10 + height: parent.height + color: 'blue' + } + Text { + leftPadding: 20 + text: model.display + } + } + } + } + + } +} From a3801ecae8b7717b3a30a2a05aa1a3539ac1b144 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sat, 3 Apr 2021 21:55:12 +0200 Subject: [PATCH 005/218] qml: map fields of tx history --- electrum/gui/qml/qeqr.py | 3 ++- electrum/gui/qml/qewallet.py | 32 ++++++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index 0df046869..dd57423eb 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -24,7 +24,7 @@ class QEQR(QObject): self.scan_ready_changed.emit() pilimage = self.convertToPILImage(image) - parseQR(pilimage) + self.parseQR(pilimage) self._ready = True @@ -50,6 +50,7 @@ class QEQR(QObject): return Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') def parseQR(self, image): + # TODO pass @pyqtProperty(bool, notify=scan_ready_changed) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 58db1b60d..1aabfcc40 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,7 +1,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex, QByteArray -from electrum.util import register_callback +from electrum.util import register_callback, Satoshis from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet @@ -10,16 +10,35 @@ class QETransactionsListModel(QAbstractListModel): super().__init__(parent) self.tx_history = [] + _logger = get_logger(__name__) + + # define listmodel rolemap + ROLES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp','incoming','bc_value', + 'bc_balance','date','label','txpos_in_block','fee','inputs','outputs') + keys = range(Qt.UserRole + 1, Qt.UserRole + 1 + len(ROLES)) + ROLENAMES = [bytearray(x.encode()) for x in ROLES] + _ROLE_MAP = dict(zip(keys, ROLENAMES)) + def rowCount(self, index): return len(self.tx_history) + def roleNames(self): + return self._ROLE_MAP + def data(self, index, role): - if role == Qt.DisplayRole: - return str(self.tx_history[index.row()]['bc_value']) + tx = self.tx_history[index.row()] + role_index = role - (Qt.UserRole + 1) + value = tx[self.ROLES[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 set_history(self, history): self.beginInsertRows(QModelIndex(), 0, len(history) - 1) self.tx_history = history + self.tx_history.reverse() self.endInsertRows() class QEWallet(QObject): @@ -39,5 +58,10 @@ class QEWallet(QObject): def get_history(self): history = self.wallet.get_detailed_history(show_addresses = True) txs = history['transactions'] + self._logger.info(txs) + # use primitives + for tx in txs: + for output in tx['outputs']: + output['value'] = output['value'].value self._historyModel.set_history(txs) From 39048fdd105b0b8b84ec74558ecaf1a5f73ede3c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sat, 3 Apr 2021 21:56:42 +0200 Subject: [PATCH 006/218] qml: UI: add most transaction fields to tx history page --- electrum/gui/qml/components/EHeader.qml | 29 ++++++ electrum/gui/qml/components/landing.qml | 5 + electrum/gui/qml/components/scan.qml | 5 + electrum/gui/qml/components/splash.qml | 2 +- electrum/gui/qml/components/tx.qml | 118 +++++++++++++++++++++--- 5 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 electrum/gui/qml/components/EHeader.qml diff --git a/electrum/gui/qml/components/EHeader.qml b/electrum/gui/qml/components/EHeader.qml new file mode 100644 index 000000000..50283313f --- /dev/null +++ b/electrum/gui/qml/components/EHeader.qml @@ -0,0 +1,29 @@ +import QtQuick 2.6 + +Item { + height: 60 + + property alias text: label.text + + Rectangle { + anchors.fill: parent + color: '#cccccc' + } + + Text { + id: label + x: 10 + anchors.verticalCenter: parent.verticalCenter + font.pointSize: 11 + color: '#202020' + } + + Rectangle { + x: 10 + width: parent.width - 20 + height: 2 + anchors.topMargin: 0 + anchors.top: label.bottom + color: '#808080' + } +} diff --git a/electrum/gui/qml/components/landing.qml b/electrum/gui/qml/components/landing.qml index 469799937..4a88d8170 100644 --- a/electrum/gui/qml/components/landing.qml +++ b/electrum/gui/qml/components/landing.qml @@ -6,6 +6,11 @@ Item { Column { width: parent.width + EHeader { + text: "Network" + width: parent.width + } + Row { Text { text: "Server: " } Text { text: Network.server } diff --git a/electrum/gui/qml/components/scan.qml b/electrum/gui/qml/components/scan.qml index a13b04615..cec1e3710 100644 --- a/electrum/gui/qml/components/scan.qml +++ b/electrum/gui/qml/components/scan.qml @@ -6,6 +6,11 @@ Item { Column { width: parent.width + EHeader { + text: "Scan QR Code" + width: parent.width + } + Item { id: voc width: parent.width diff --git a/electrum/gui/qml/components/splash.qml b/electrum/gui/qml/components/splash.qml index cd882d712..bb555f36a 100644 --- a/electrum/gui/qml/components/splash.qml +++ b/electrum/gui/qml/components/splash.qml @@ -3,7 +3,7 @@ import QtQuick 2.0 Item { Rectangle { anchors.fill: parent - color: '#111111' + color: '#111144' } Image { diff --git a/electrum/gui/qml/components/tx.qml b/electrum/gui/qml/components/tx.qml index 116b18d25..f8aefd0f1 100644 --- a/electrum/gui/qml/components/tx.qml +++ b/electrum/gui/qml/components/tx.qml @@ -2,38 +2,130 @@ import QtQuick 2.6 Item { id: rootItem -// height: 800 + Column { width: parent.width -// height: parent.height - Text { - text: "Transactions" + EHeader { + text: "History" + width: parent.width } ListView { width: parent.width height: 200 -// anchors.bottom: rootItem.bottom model: Daemon.currentWallet.historyModel delegate: Item { + id: delegate width: parent.width - height: line.height + height: txinfo.height + + MouseArea { + anchors.fill: delegate + onClicked: extinfo.visible = !extinfo.visible + } + Row { - id: line + id: txinfo Rectangle { - width: 10 + width: 4 height: parent.height - color: 'blue' + color: model.incoming ? 'green' : 'red' } - Text { - leftPadding: 20 - text: model.display + + Column { + + Row { + id: baseinfo + spacing: 10 + + + Image { + readonly property variant tx_icons : [ + "../../icons/unconfirmed.png", + "../../icons/clock1.png", + "../../icons/clock2.png", + "../../icons/clock3.png", + "../../icons/clock4.png", + "../../icons/clock5.png", + "../../icons/confirmed.png" + ] + + width: 32 + height: 32 + anchors.verticalCenter: parent.verticalCenter + source: tx_icons[Math.min(6,Math.floor(model.confirmations/20))] + } + + Column { + id: content + width: delegate.width - x - valuefee.width + + Text { + text: model.label !== '' ? model.label : '' + color: model.label !== '' ? 'black' : 'gray' + } + Text { + font.pointSize: 7 + text: model.date + } + } + + Column { + id: valuefee + width: delegate.width * 0.25 + Text { + text: model.bc_value + } + Text { + font.pointSize: 7 + text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') + } + } + } + + Row { + id: extinfo + visible: false + + Column { + id: extinfoinner + Text { + font.pointSize: 6 + text: 'txid: ' + model.txid + } + Text { + font.pointSize: 7 + text: 'height: ' + model.height + } + Text { + font.pointSize: 7 + text: 'confirmations: ' + model.confirmations + } + Text { + font.pointSize: 7 + text: { + for (var i=0; i < Object.keys(model.outputs).length; i++) { + if (model.outputs[i].value === model.bc_value) { + return 'address: ' + model.outputs[i].address + } + } + } + } + } + } + + } } - } + } // delegate } + EButton { + text: 'Back' + onClicked: app.stack.pop() + } } + } From d195fce82d3fa2f853fe063a1c09f474267f74fb Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sun, 4 Apr 2021 13:56:45 +0200 Subject: [PATCH 007/218] qml: hook any qml supporting plugins, add test plugin This allows different platforms to have their own UI components while still leveraging the common set of QObjects and utility components. --- electrum/gui/qml/__init__.py | 44 ++++++++++++++++++++------- electrum/plugins/qml_test/__init__.py | 5 +++ electrum/plugins/qml_test/qml.py | 12 ++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 electrum/plugins/qml_test/__init__.py create mode 100644 electrum/plugins/qml_test/qml.py diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 581ff0bda..089c48ca8 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -44,18 +44,32 @@ class ElectrumQmlApplication(QGuiApplication): def __init__(self, args, daemon): super().__init__(args) - qmlRegisterType(QEWalletListModel, 'QElectrum', 1, 0, 'QEWalletListModel') - qmlRegisterType(QEWallet, 'QElectrum', 1, 0, 'QEWallet') + qmlRegisterType(QEWalletListModel, 'Electrum', 1, 0, 'WalletListModel') + qmlRegisterType(QEWallet, 'Electrum', 1, 0, 'Wallet') self.engine = QQmlApplicationEngine(parent=self) self.context = self.engine.rootContext() - self.qen = QENetwork(daemon.network) - self.context.setContextProperty('Network', self.qen) - self.qed = QEDaemon(daemon) - self.context.setContextProperty('Daemon', self.qed) - self.qeqr = QEQR() - self.context.setContextProperty('QR', self.qeqr) - self.engine.load(QUrl('electrum/gui/qml/components/main.qml')) + self._singletons['network'] = QENetwork(daemon.network) + self._singletons['daemon'] = QEDaemon(daemon) + self._singletons['qr'] = QEQR() + self.context.setContextProperty('Network', self._singletons['network']) + self.context.setContextProperty('Daemon', self._singletons['daemon']) + self.context.setContextProperty('QR', self._singletons['qr']) + + # get notified whether root QML document loads or not + self.engine.objectCreated.connect(self.objectCreated) + + _logger = get_logger(__name__) + _valid = True + _singletons = {} + + # slot is called after loading root QML. If object is None, it has failed. + @pyqtSlot('QObject*', 'QUrl') + def objectCreated(self, object, url): + self._logger.info(str(object)) + if object is None: + self._valid = False + self.engine.objectCreated.disconnect(self.objectCreated) class ElectrumGui(Logger): @@ -73,13 +87,19 @@ class ElectrumGui(Logger): QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) # if hasattr(QGuiApplication, 'setDesktopFileName'): # QGuiApplication.setDesktopFileName('electrum.desktop') + if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling); + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + self.gui_thread = threading.current_thread() self.config = config self.daemon = daemon self.plugins = plugins self.app = ElectrumQmlApplication(sys.argv, self.daemon) - # TODO when plugin support. run_hook('init_qml', self) + # Initialize any QML plugins + run_hook('init_qml', self.app.engine) + self.app.engine.load('electrum/gui/qml/components/main.qml') def close(self): # for window in self.windows: @@ -93,7 +113,9 @@ class ElectrumGui(Logger): self.app.quit() def main(self): - self.app.exec_() + if self.app._valid: + self.logger.info('Entering main loop') + self.app.exec_() def stop(self): self.logger.info('closing GUI') diff --git a/electrum/plugins/qml_test/__init__.py b/electrum/plugins/qml_test/__init__.py new file mode 100644 index 000000000..62e390176 --- /dev/null +++ b/electrum/plugins/qml_test/__init__.py @@ -0,0 +1,5 @@ +from electrum.i18n import _ + +fullname = 'QML Plugin Test' +description = '%s\n%s' % (_("Plugin to test QML integration from plugins."), _("Note: Used for development")) +available_for = ['qml'] diff --git a/electrum/plugins/qml_test/qml.py b/electrum/plugins/qml_test/qml.py new file mode 100644 index 000000000..840ae7f5d --- /dev/null +++ b/electrum/plugins/qml_test/qml.py @@ -0,0 +1,12 @@ +import os +from PyQt5.QtCore import QUrl +from PyQt5.QtQml import QQmlApplicationEngine +from electrum.plugin import hook, BasePlugin + +class Plugin(BasePlugin): + def __init__(self, parent, config, name): + BasePlugin.__init__(self, parent, config, name) + + @hook + def init_qml(self, engine: QQmlApplicationEngine): + pass From e534c5d8343c1aba382ccca282c38a4c49ef9d2c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 5 Apr 2021 12:24:45 +0200 Subject: [PATCH 008/218] qml: switch to QtQuick Controls --- electrum/gui/qml/components/EButton.qml | 41 ------ electrum/gui/qml/components/EHeader.qml | 29 ---- electrum/gui/qml/components/History.qml | 134 ++++++++++++++++++ electrum/gui/qml/components/NetworkStats.qml | 21 +++ electrum/gui/qml/components/QRScan.qml | 41 ++++++ electrum/gui/qml/components/Scan.qml | 23 +++ electrum/gui/qml/components/Send.qml | 69 +++++++++ .../qml/components/{splash.qml => Splash.qml} | 2 + electrum/gui/qml/components/Wallets.qml | 32 +++++ electrum/gui/qml/components/landing.qml | 63 +++----- electrum/gui/qml/components/main.qml | 55 ++++++- electrum/gui/qml/components/scan.qml | 63 -------- electrum/gui/qml/components/tx.qml | 131 ----------------- 13 files changed, 388 insertions(+), 316 deletions(-) delete mode 100644 electrum/gui/qml/components/EButton.qml delete mode 100644 electrum/gui/qml/components/EHeader.qml create mode 100644 electrum/gui/qml/components/History.qml create mode 100644 electrum/gui/qml/components/NetworkStats.qml create mode 100644 electrum/gui/qml/components/QRScan.qml create mode 100644 electrum/gui/qml/components/Scan.qml create mode 100644 electrum/gui/qml/components/Send.qml rename electrum/gui/qml/components/{splash.qml => Splash.qml} (89%) create mode 100644 electrum/gui/qml/components/Wallets.qml delete mode 100644 electrum/gui/qml/components/scan.qml delete mode 100644 electrum/gui/qml/components/tx.qml diff --git a/electrum/gui/qml/components/EButton.qml b/electrum/gui/qml/components/EButton.qml deleted file mode 100644 index 87f7d459c..000000000 --- a/electrum/gui/qml/components/EButton.qml +++ /dev/null @@ -1,41 +0,0 @@ -import QtQuick 2.6 - -Item { - id: rootItem - width: visbut.width + 10 - height: visbut.height + 10 - - signal clicked - property string text - - Rectangle { - id: visbut - border { - color: '#444444' - width: 2 - } - color: '#dddddd' - radius: 4 - - anchors.centerIn: parent - width: buttonText.width - height: buttonText.height - - MouseArea { - anchors.fill: parent - onClicked: rootItem.clicked() - } - } - - Text { - id: buttonText - leftPadding: 30 - rightPadding: 30 - topPadding: 20 - bottomPadding: 20 - verticalAlignment: Text.AlignVCenter - text: rootItem.text - color: 'red' - } - -} diff --git a/electrum/gui/qml/components/EHeader.qml b/electrum/gui/qml/components/EHeader.qml deleted file mode 100644 index 50283313f..000000000 --- a/electrum/gui/qml/components/EHeader.qml +++ /dev/null @@ -1,29 +0,0 @@ -import QtQuick 2.6 - -Item { - height: 60 - - property alias text: label.text - - Rectangle { - anchors.fill: parent - color: '#cccccc' - } - - Text { - id: label - x: 10 - anchors.verticalCenter: parent.verticalCenter - font.pointSize: 11 - color: '#202020' - } - - Rectangle { - x: 10 - width: parent.width - 20 - height: 2 - anchors.topMargin: 0 - anchors.top: label.bottom - color: '#808080' - } -} diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml new file mode 100644 index 000000000..cc7f898c6 --- /dev/null +++ b/electrum/gui/qml/components/History.qml @@ -0,0 +1,134 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 + +Item { + id: rootItem + + property string title: 'History' + + Column { + width: parent.width + + ListView { + width: parent.width + height: 200 + + model: Daemon.currentWallet.historyModel + delegate: Item { + id: delegate + width: ListView.view.width + height: txinfo.height + + MouseArea { + anchors.fill: delegate + onClicked: extinfo.visible = !extinfo.visible + } + + GridLayout { + id: txinfo + columns: 4 + + x: 6 + width: delegate.width - 12 + + Item { + id: indicator + Layout.fillHeight: true + Layout.rowSpan: 2 + Rectangle { + width: 3 + color: model.incoming ? 'green' : 'red' + y: 2 + height: parent.height - 4 + } + } + + Image { + readonly property variant tx_icons : [ + "../../../gui/icons/unconfirmed.png", + "../../../gui/icons/clock1.png", + "../../../gui/icons/clock2.png", + "../../../gui/icons/clock3.png", + "../../../gui/icons/clock4.png", + "../../../gui/icons/clock5.png", + "../../../gui/icons/confirmed.png" + ] + + sourceSize.width: 32 + sourceSize.height: 32 + Layout.alignment: Qt.AlignVCenter + source: tx_icons[Math.min(6,model.confirmations)] + } + + Column { + Layout.fillWidth: true + + Label { + text: model.label !== '' ? model.label : '' + color: model.label !== '' ? 'black' : 'gray' + font.bold: model.label !== '' ? true : false + } + Label { + font.pointSize: 7 + text: model.date + } + } + + Column { + id: valuefee + Label { + text: model.bc_value + font.bold: true + } + Label { + font.pointSize: 6 + text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') + } + } + + GridLayout { + id: extinfo + visible: false + columns: 2 + Layout.columnSpan: 3 + + Label { text: 'txid' } + Label { + font.pointSize: 6 + text: model.txid + elide: Text.ElideMiddle + Layout.fillWidth: true + } + Label { text: 'height' } + Label { + font.pointSize: 7 + text: model.height + } + Label { text: 'confirmations' } + Label { + font.pointSize: 7 + text: model.confirmations + } + Label { text: 'address' } + Label { + font.pointSize: 7 + elide: Text.ElideMiddle + Layout.fillWidth: true + text: { + for (var i=0; i < Object.keys(model.outputs).length; i++) { + if (model.outputs[i].value === model.bc_value) { + return model.outputs[i].address + } + } + } + } + } + + } + } // delegate + } + + } + +} diff --git a/electrum/gui/qml/components/NetworkStats.qml b/electrum/gui/qml/components/NetworkStats.qml new file mode 100644 index 000000000..ef650cd1e --- /dev/null +++ b/electrum/gui/qml/components/NetworkStats.qml @@ -0,0 +1,21 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +Item { + property string title: qsTr('Network') + + GridLayout { + columns: 2 + + Label { text: qsTr("Server: "); color: Material.primaryHighlightedTextColor; font.bold: true } + Label { text: Network.server } + Label { text: qsTr("Local Height: "); color: Material.primaryHighlightedTextColor; font.bold: true } + Label { text: Network.height } + Label { text: qsTr("Status: "); color: Material.primaryHighlightedTextColor; font.bold: true } + Label { text: Network.status } + Label { text: qsTr("Wallet: "); color: Material.primaryHighlightedTextColor; font.bold: true } + Label { text: Daemon.walletName } + } +} diff --git a/electrum/gui/qml/components/QRScan.qml b/electrum/gui/qml/components/QRScan.qml new file mode 100644 index 000000000..f04c4aa57 --- /dev/null +++ b/electrum/gui/qml/components/QRScan.qml @@ -0,0 +1,41 @@ +import QtQuick 2.6 +import QtMultimedia 5.6 + +Item { + + VideoOutput { + id: vo + anchors.fill: parent + source: camera + fillMode: VideoOutput.PreserveAspectCrop + } + + MouseArea { + anchors.fill: parent + onClicked: { + vo.grabToImage(function(result) { + console.log("grab: image=" + (result.image !== undefined) + " url=" + result.url) + if (result.image !== undefined) { + console.log('scanning image for QR') + QR.scanImage(result.image) + } + }) + } + } + + Camera { + id: camera + deviceId: QtMultimedia.defaultCamera.deviceId + viewfinder.resolution: "640x480" + + function dumpstats() { + console.log(camera.viewfinder.resolution) + console.log(camera.viewfinder.minimumFrameRate) + console.log(camera.viewfinder.maximumFrameRate) + var resolutions = camera.supportedViewfinderResolutions() + resolutions.forEach(function(item, i) { + console.log('' + item.width + 'x' + item.height) + }) + } + } +} diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml new file mode 100644 index 000000000..61b8d179a --- /dev/null +++ b/electrum/gui/qml/components/Scan.qml @@ -0,0 +1,23 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 + +Item { + + property bool toolbar: false + property string title: 'scan' + + QRScan { + anchors.top: parent.top + anchors.bottom: button.top + width: parent.width + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + id: button + anchors.bottom: parent.bottom + text: 'Cancel' + onClicked: app.stack.pop() + } + +} diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml new file mode 100644 index 000000000..81e27890f --- /dev/null +++ b/electrum/gui/qml/components/Send.qml @@ -0,0 +1,69 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 + +Item { + id: rootItem + + property string title: 'Send' + + GridLayout { + width: rootItem.width - 12 + anchors.horizontalCenter: parent.horizontalCenter + columns: 4 + + Label { + Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter + text: "Current Balance: 0 mBTC" + } + + Label { + text: "Recipient" + } + + TextField { + id: address + Layout.columnSpan: 3 + placeholderText: 'Paste address or invoice' + Layout.fillWidth: true + } + + Label { + text: "Amount" + } + + TextField { + id: amount + placeholderText: 'Amount' + } + + Label { + text: "Fee" + } + + TextField { + id: fee + placeholderText: 'sat/vB' + } + + Column { + Layout.fillWidth: true + Layout.columnSpan: 4 + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: 'Pay' + onClicked: { + var i_amount = parseInt(amount.text) + if (isNaN(i_amount)) + return + var result = Daemon.currentWallet.send_onchain(address.text, i_amount, undefined, false) + if (result) + app.stack.pop() + } + } + } + } + +} diff --git a/electrum/gui/qml/components/splash.qml b/electrum/gui/qml/components/Splash.qml similarity index 89% rename from electrum/gui/qml/components/splash.qml rename to electrum/gui/qml/components/Splash.qml index bb555f36a..8afc04f68 100644 --- a/electrum/gui/qml/components/splash.qml +++ b/electrum/gui/qml/components/Splash.qml @@ -1,6 +1,8 @@ import QtQuick 2.0 Item { + property bool toolbar: false + Rectangle { anchors.fill: parent color: '#111144' diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml new file mode 100644 index 000000000..5ce2fc859 --- /dev/null +++ b/electrum/gui/qml/components/Wallets.qml @@ -0,0 +1,32 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 + +Item { + property string title: 'Wallets' + + ListView { + width: parent.width + height: 200 + model: Daemon.activeWallets + + delegate: Item { + width: ListView.view.width + + RowLayout { + x: 20 + spacing: 20 + + Image { + source: "../../../gui/kivy/theming/light/wallet.png" + } + + Label { + font.pointSize: 13 + text: model.display + } + } + } + } + +} diff --git a/electrum/gui/qml/components/landing.qml b/electrum/gui/qml/components/landing.qml index 4a88d8170..73f3fcf5c 100644 --- a/electrum/gui/qml/components/landing.qml +++ b/electrum/gui/qml/components/landing.qml @@ -1,62 +1,31 @@ import QtQuick 2.6 -import QtQuick.Controls 1.4 +import QtQuick.Controls 2.0 import QtQml 2.6 Item { - Column { - width: parent.width + property string title: 'Network' - EHeader { - text: "Network" - width: parent.width - } + property QtObject menu: Menu { + MenuItem { text: 'Wallets'; onTriggered: stack.push(Qt.resolvedUrl('Wallets.qml')) } + MenuItem { text: 'Network'; onTriggered: stack.push(Qt.resolvedUrl('NetworkStats.qml')) } + } - Row { - Text { text: "Server: " } - Text { text: Network.server } - } - Row { - Text { text: "Local Height: " } - Text { text: Network.height } - } - Row { - Text { text: "Status: " } - Text { text: Network.status } - } - Row { - Text { text: "Wallet: " } - Text { text: Daemon.walletName } - } + Column { + width: parent.width - EButton { + Button { text: 'Scan QR Code' - onClicked: app.stack.push(Qt.resolvedUrl('scan.qml')) + onClicked: app.stack.push(Qt.resolvedUrl('Scan.qml')) } - EButton { - text: 'Show TXen' - onClicked: app.stack.push(Qt.resolvedUrl('tx.qml')) + Button { + text: 'Send' + onClicked: app.stack.push(Qt.resolvedUrl('Send.qml')) } - ListView { - width: parent.width - height: 200 - model: Daemon.activeWallets - delegate: Item { - width: parent.width - - Row { - Rectangle { - width: 10 - height: parent.height - color: 'red' - } - Text { - leftPadding: 20 - text: model.display - } - } - } + Button { + text: 'Show TX History' + onClicked: app.stack.push(Qt.resolvedUrl('History.qml')) } } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 233c671f3..bcbaeb002 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -1,5 +1,8 @@ import QtQuick 2.6 -import QtQuick.Controls 1.4 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + import QtQml 2.6 import QtMultimedia 5.6 @@ -9,22 +12,64 @@ ApplicationWindow visible: true width: 480 height: 800 - color: '#dddddd' + + Material.theme: Material.Dark + Material.primary: Material.Indigo + Material.accent: Material.LightBlue property alias stack: mainStackView + header: ToolBar { + id: toolbar + RowLayout { + anchors.fill: parent + ToolButton { + text: qsTr("‹") + enabled: stack.currentItem.StackView.index > 0 + onClicked: stack.pop() + } + Label { + text: stack.currentItem.title + elide: Label.ElideRight + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + Layout.fillWidth: true + } + ToolButton { + text: qsTr("⋮") + onClicked: { + stack.currentItem.menu.open() + // position the menu to the right + stack.currentItem.menu.x = toolbar.width - stack.currentItem.menu.width + } + } + } + } + StackView { id: mainStackView anchors.fill: parent - initialItem: Qt.resolvedUrl('splash.qml') + initialItem: Qt.resolvedUrl('landing.qml') } Timer { id: splashTimer - interval: 400 + interval: 1000 onTriggered: { - mainStackView.push(Qt.resolvedUrl('landing.qml')) + splash.opacity = 0 + } + } + + Splash { + id: splash + anchors.top: header.top + anchors.bottom: app.contentItem.bottom + width: app.width + z: 1000 + + Behavior on opacity { + NumberAnimation { duration: 300 } } } diff --git a/electrum/gui/qml/components/scan.qml b/electrum/gui/qml/components/scan.qml deleted file mode 100644 index cec1e3710..000000000 --- a/electrum/gui/qml/components/scan.qml +++ /dev/null @@ -1,63 +0,0 @@ -import QtQuick 2.6 -import QtMultimedia 5.6 - - -Item { - Column { - width: parent.width - - EHeader { - text: "Scan QR Code" - width: parent.width - } - - Item { - id: voc - width: parent.width - height: parent.width - - VideoOutput { - id: vo - anchors.fill: parent - source: camera - //fillMode: VideoOutput.PreserveAspectCrop - } - - MouseArea { - anchors.fill: parent - onClicked: { - vo.grabToImage(function(result) { - console.log("grab: image=" + (result.image !== undefined) + " url=" + result.url) - if (result.image !== undefined) { - console.log('scanning image for QR') - QR.scanImage(result.image) - } - }) - } - } - } - - EButton { - text: 'Exit' - onClicked: app.stack.pop() - } - } - - Camera { - id: camera - deviceId: QtMultimedia.defaultCamera.deviceId - viewfinder.resolution: "640x480" - - function dumpstats() { - console.log(camera.viewfinder.resolution) - console.log(camera.viewfinder.minimumFrameRate) - console.log(camera.viewfinder.maximumFrameRate) - var resolutions = camera.supportedViewfinderResolutions() - resolutions.forEach(function(item, i) { - console.log('' + item.width + 'x' + item.height) - }) - } - } - - -} diff --git a/electrum/gui/qml/components/tx.qml b/electrum/gui/qml/components/tx.qml deleted file mode 100644 index f8aefd0f1..000000000 --- a/electrum/gui/qml/components/tx.qml +++ /dev/null @@ -1,131 +0,0 @@ -import QtQuick 2.6 - -Item { - id: rootItem - - Column { - width: parent.width - - EHeader { - text: "History" - width: parent.width - } - - ListView { - width: parent.width - height: 200 - - model: Daemon.currentWallet.historyModel - delegate: Item { - id: delegate - width: parent.width - height: txinfo.height - - MouseArea { - anchors.fill: delegate - onClicked: extinfo.visible = !extinfo.visible - } - - Row { - id: txinfo - Rectangle { - width: 4 - height: parent.height - color: model.incoming ? 'green' : 'red' - } - - Column { - - Row { - id: baseinfo - spacing: 10 - - - Image { - readonly property variant tx_icons : [ - "../../icons/unconfirmed.png", - "../../icons/clock1.png", - "../../icons/clock2.png", - "../../icons/clock3.png", - "../../icons/clock4.png", - "../../icons/clock5.png", - "../../icons/confirmed.png" - ] - - width: 32 - height: 32 - anchors.verticalCenter: parent.verticalCenter - source: tx_icons[Math.min(6,Math.floor(model.confirmations/20))] - } - - Column { - id: content - width: delegate.width - x - valuefee.width - - Text { - text: model.label !== '' ? model.label : '' - color: model.label !== '' ? 'black' : 'gray' - } - Text { - font.pointSize: 7 - text: model.date - } - } - - Column { - id: valuefee - width: delegate.width * 0.25 - Text { - text: model.bc_value - } - Text { - font.pointSize: 7 - text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') - } - } - } - - Row { - id: extinfo - visible: false - - Column { - id: extinfoinner - Text { - font.pointSize: 6 - text: 'txid: ' + model.txid - } - Text { - font.pointSize: 7 - text: 'height: ' + model.height - } - Text { - font.pointSize: 7 - text: 'confirmations: ' + model.confirmations - } - Text { - font.pointSize: 7 - text: { - for (var i=0; i < Object.keys(model.outputs).length; i++) { - if (model.outputs[i].value === model.bc_value) { - return 'address: ' + model.outputs[i].address - } - } - } - } - } - } - - - } - } - } // delegate - } - - EButton { - text: 'Back' - onClicked: app.stack.pop() - } - } - -} From e3c63ae39593eb99c8c2d9f5779fed8ce777fbd3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 6 Apr 2021 01:53:46 +0200 Subject: [PATCH 009/218] qml: initial implementation of new wallet conversation --- .../gui/qml/components/NewWalletWizard.qml | 190 ++++++++++++++++ .../gui/qml/components/WizardComponent.qml | 10 + .../gui/qml/components/WizardComponents.qml | 205 ++++++++++++++++++ electrum/gui/qml/components/landing.qml | 27 ++- 4 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 electrum/gui/qml/components/NewWalletWizard.qml create mode 100644 electrum/gui/qml/components/WizardComponent.qml create mode 100644 electrum/gui/qml/components/WizardComponents.qml diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml new file mode 100644 index 000000000..3eec5b7b8 --- /dev/null +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -0,0 +1,190 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +Dialog { + id: walletwizard + + title: qsTr('New Wallet') + modal: true + + enter: null // disable transition + + property var wizard_data + + function _setWizardData(wdata) { + wizard_data = {} + Object.assign(wizard_data, wdata) // deep copy + console.log('wizard data is now :' + JSON.stringify(wizard_data)) + } + + // helper function to dynamically load wizard page components + // and add them to the SwipeView + // Here we do some manual binding of page.valid -> pages.pagevalid + // to propagate the state without the binding going stale + function _loadNextComponent(comp, wdata={}) { + var page = comp.createObject(pages, { + 'visible': Qt.binding(function() { + return pages.currentItem === this + }) + }) + page.validChanged.connect(function() { + pages.pagevalid = page.valid + } ) + page.lastChanged.connect(function() { + pages.lastpage = page.last + } ) + Object.assign(page.wizard_data, wdata) // deep copy + pages.pagevalid = page.valid + + return page + } + + // State transition functions. These functions are called when the 'Next' + // button is pressed. They take data from the component, add it to the + // wizard_data object, and depending on the data create the next page + // in the conversation. + + function walletnameDone(d) { + console.log('wallet name done') + wizard_data['wallet_name'] = pages.currentItem.wallet_name + var page = _loadNextComponent(components.wallettype, wizard_data) + page.next.connect(function() {wallettypeDone()}) + } + + function wallettypeDone(d) { + console.log('wallet type done') + wizard_data['wallet_type'] = pages.currentItem.wallet_type + var page = _loadNextComponent(components.keystore, wizard_data) + page.next.connect(function() {keystoretypeDone()}) + } + + function keystoretypeDone(d) { + console.log('keystore type done') + wizard_data['keystore_type'] = pages.currentItem.keystore_type + var page + switch(wizard_data['keystore_type']) { + case 'createseed': + page = _loadNextComponent(components.createseed, wizard_data) + page.next.connect(function() {createseedDone()}) + break + case 'haveseed': + page = _loadNextComponent(components.haveseed, wizard_data) + page.next.connect(function() {haveseedDone()}) + break +// case 'masterkey' +// case 'hardware' + } + } + + function createseedDone(d) { + console.log('create seed done') + wizard_data['seed'] = pages.currentItem.seed + var page = _loadNextComponent(components.confirmseed, wizard_data) + page.next.connect(function() {confirmseedDone()}) + } + + function confirmseedDone(d) { + console.log('confirm seed done') + var page = _loadNextComponent(components.walletpassword, wizard_data) + page.next.connect(function() {walletpasswordDone()}) + page.last = true + } + + function haveseedDone(d) { + console.log('have seed done') + wizard_data['seed'] = pages.currentItem.seed + var page = _loadNextComponent(components.walletpassword, wizard_data) + page.next.connect(function() {walletpasswordDone()}) + page.last = true + } + + function walletpasswordDone(d) { + console.log('walletpassword done') + wizard_data['password'] = pages.currentItem.password + wizard_data['encrypt'] = pages.currentItem.encrypt + var page = _loadNextComponent(components.walletpassword, wizard_data) + } + + + ColumnLayout { + anchors.fill: parent + + SwipeView { + id: pages + Layout.fillHeight: true + interactive: false + + function prev() { + currentIndex = currentIndex - 1 + _setWizardData(pages.contentChildren[currentIndex].wizard_data) + pages.pagevalid = pages.contentChildren[currentIndex].valid + pages.contentChildren[currentIndex+1].destroy() + } + + function next() { + currentItem.next() + currentIndex = currentIndex + 1 + } + + function finalize() { + walletwizard.accept() + } + + property bool pagevalid: false + property bool lastpage: false + + Component.onCompleted: { + _setWizardData({}) + var start = _loadNextComponent(components.walletname) + start.next.connect(function() {walletnameDone()}) + } + + } + + PageIndicator { + id: indicator + + Layout.alignment: Qt.AlignHCenter + + count: pages.count + currentIndex: pages.currentIndex + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Button { + visible: pages.currentIndex == 0 + text: qsTr("Cancel") + onClicked: walletwizard.close() + } + + Button { + visible: pages.currentIndex > 0 + text: qsTr('Back') + onClicked: pages.prev() + } + + Button { + text: "Next" + visible: !pages.lastpage + enabled: pages.pagevalid + onClicked: pages.next() + } + + Button { + text: "Create" + visible: pages.lastpage + enabled: pages.pagevalid + onClicked: pages.finalize() + } + + } + } + + WizardComponents { + id: components + } + +} + diff --git a/electrum/gui/qml/components/WizardComponent.qml b/electrum/gui/qml/components/WizardComponent.qml new file mode 100644 index 000000000..a07885259 --- /dev/null +++ b/electrum/gui/qml/components/WizardComponent.qml @@ -0,0 +1,10 @@ +import QtQuick 2.0 + +Item { + signal next + property var wizard_data : ({}) + property bool valid + property bool last: false +// onValidChanged: console.log('valid change in component itself') +// onWizard_dataChanged: console.log('wizard data changed in ') +} diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml new file mode 100644 index 000000000..6bacca682 --- /dev/null +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -0,0 +1,205 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +Item { + property Component walletname: Component { + WizardComponent { + valid: wallet_name.text.length > 0 + property alias wallet_name: wallet_name.text + GridLayout { + columns: 1 + Label { text: qsTr('Wallet name') } + TextField { + id: wallet_name + } + } + } + } + + property Component wallettype: Component { + WizardComponent { + valid: wallettypegroup.checkedButton !== null + property string wallet_type + + ButtonGroup { + id: wallettypegroup + onCheckedButtonChanged: { + wallet_type = checkedButton.wallettype + } + } + + GridLayout { + columns: 1 + Label { text: qsTr('What kind of wallet do you want to create?') } + RadioButton { + ButtonGroup.group: wallettypegroup + property string wallettype: 'standard' + checked: true + text: qsTr('Standard Wallet') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: '2fa' + text: qsTr('Wallet with two-factor authentication') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: 'multisig' + text: qsTr('Multi-signature wallet') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: 'import' + text: qsTr('Import Bitcoin addresses or private keys') + } + } + } + } + + property Component keystore: Component { + WizardComponent { + valid: keystoregroup.checkedButton !== null + property string keystore_type + + ButtonGroup { + id: keystoregroup + onCheckedButtonChanged: { + keystore_type = checkedButton.keystoretype + } + } + + GridLayout { + columns: 1 + Label { text: qsTr('What kind of wallet do you want to create?') } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'createseed' + checked: true + text: qsTr('Create a new seed') + } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'haveseed' + text: qsTr('I already have a seed') + } + RadioButton { + enabled: false + ButtonGroup.group: keystoregroup + property string keystoretype: 'masterkey' + text: qsTr('Use a master key') + } + RadioButton { + enabled: false + ButtonGroup.group: keystoregroup + property string keystoretype: 'hardware' + text: qsTr('Use a hardware device') + } + } + } + + } + + property Component createseed: Component { + WizardComponent { + valid: true + property alias seed: seedtext.text + property alias extend: extendcb.checked + GridLayout { + columns: 1 + Label { text: qsTr('Generating seed') } + TextArea { + id: seedtext + text: 'test this is a fake seed as you might expect' + readOnly: true + Layout.fillWidth: true + wrapMode: TextInput.WordWrap + } + CheckBox { + id: extendcb + text: qsTr('Extend seed with custom words') + } + } + } + } + + property Component haveseed: Component { + WizardComponent { + valid: true + property alias seed: seedtext.text + property alias extend: extendcb.checked + property alias bip39: bip39cb.checked + GridLayout { + columns: 1 + Label { text: qsTr('Enter your seed') } + TextArea { + id: seedtext + wrapMode: TextInput.WordWrap + Layout.fillWidth: true + } + CheckBox { + id: extendcb + enabled: true + text: qsTr('Extend seed with custom words') + } + CheckBox { + id: bip39cb + enabled: true + text: qsTr('BIP39') + } + } + } + } + + property Component confirmseed: Component { + WizardComponent { + valid: confirm.text !== '' + Layout.fillWidth: true + + GridLayout { + Layout.fillWidth: true + columns: 1 + Label { text: qsTr('Confirm your seed (re-enter)') } + TextArea { + id: confirm + wrapMode: TextInput.WordWrap + Layout.fillWidth: true + onTextChanged: { + console.log("TODO: verify seed") + } + } + } + } + } + + property Component walletpassword: Component { + WizardComponent { + valid: password1.text === password2.text + + property alias password: password1.text + property alias encrypt: doencrypt.checked + GridLayout { + columns: 1 + Label { text: qsTr('Password protect wallet?') } + TextField { + id: password1 + echoMode: TextInput.Password + } + TextField { + id: password2 + echoMode: TextInput.Password + } + CheckBox { + id: doencrypt + enabled: password1.text !== '' + text: qsTr('Encrypt wallet') + } + } + } + } + + +} diff --git a/electrum/gui/qml/components/landing.qml b/electrum/gui/qml/components/landing.qml index 73f3fcf5c..55c32c145 100644 --- a/electrum/gui/qml/components/landing.qml +++ b/electrum/gui/qml/components/landing.qml @@ -1,8 +1,10 @@ import QtQuick 2.6 -import QtQuick.Controls 2.0 +import QtQuick.Controls 2.3 import QtQml 2.6 Item { + id: rootItem + property string title: 'Network' property QtObject menu: Menu { @@ -28,7 +30,30 @@ Item { onClicked: app.stack.push(Qt.resolvedUrl('History.qml')) } + Button { + text: 'Create Wallet' + onClicked: { + var dialog = newWalletWizard.createObject(rootItem) + dialog.open() + } + } + } + Component { + id: newWalletWizard + NewWalletWizard { + parent: Overlay.overlay + x: 12 + y: 12 + width: parent.width - 24 + height: parent.height - 24 + + Overlay.modal: Rectangle { + color: "#aa000000" + } + + } + } } From 1260720bb6bbb925e608f02ce6a9db4e78c7b228 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 6 Apr 2021 11:27:46 +0200 Subject: [PATCH 010/218] qml: qml test plugin --- electrum/gui/qml/__init__.py | 2 +- electrum/plugins/qml_test/qml.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 089c48ca8..ed7b1e44c 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -98,7 +98,7 @@ class ElectrumGui(Logger): self.app = ElectrumQmlApplication(sys.argv, self.daemon) # Initialize any QML plugins - run_hook('init_qml', self.app.engine) + run_hook('init_qml', self) self.app.engine.load('electrum/gui/qml/components/main.qml') def close(self): diff --git a/electrum/plugins/qml_test/qml.py b/electrum/plugins/qml_test/qml.py index 840ae7f5d..bfe54506e 100644 --- a/electrum/plugins/qml_test/qml.py +++ b/electrum/plugins/qml_test/qml.py @@ -1,12 +1,17 @@ -import os -from PyQt5.QtCore import QUrl +from typing import TYPE_CHECKING from PyQt5.QtQml import QQmlApplicationEngine from electrum.plugin import hook, BasePlugin +from electrum.logging import get_logger + +if TYPE_CHECKING: + from electrum.gui.qml import ElectrumGui class Plugin(BasePlugin): def __init__(self, parent, config, name): BasePlugin.__init__(self, parent, config, name) + _logger = get_logger(__name__) + @hook - def init_qml(self, engine: QQmlApplicationEngine): - pass + def init_qml(self, gui: 'ElectrumGui'): + self._logger.debug('init_qml hook called') From 599b01f009e9d89148b1a2bdde704e747532485f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 6 Apr 2021 11:35:04 +0200 Subject: [PATCH 011/218] qml: some more boilerplate, init language, SIGINT signal handler --- electrum/gui/qml/__init__.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index ed7b1e44c..444f0a88b 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -15,14 +15,14 @@ try: except Exception: sys.exit("Error: Could not import PyQt5.QtQml on Linux systems, you may try 'sudo apt-get install python3-pyqt5.qtquick'") -from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QUrl +from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QUrl, QLocale, QTimer from PyQt5.QtGui import QGuiApplication from PyQt5.QtQml import qmlRegisterType, QQmlComponent, QQmlApplicationEngine from PyQt5.QtQuick import QQuickView import PyQt5.QtCore as QtCore import PyQt5.QtQml as QtQml -from electrum.i18n import _, set_language +from electrum.i18n import _, set_language, languages from electrum.plugin import run_hook from electrum.base_wizard import GoBack from electrum.util import (UserCancelled, profiler, @@ -59,14 +59,12 @@ class ElectrumQmlApplication(QGuiApplication): # get notified whether root QML document loads or not self.engine.objectCreated.connect(self.objectCreated) - _logger = get_logger(__name__) _valid = True _singletons = {} # slot is called after loading root QML. If object is None, it has failed. @pyqtSlot('QObject*', 'QUrl') def objectCreated(self, object, url): - self._logger.info(str(object)) if object is None: self._valid = False self.engine.objectCreated.disconnect(self.objectCreated) @@ -75,7 +73,7 @@ class ElectrumGui(Logger): @profiler def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): - # TODO set_language(config.get('language', get_default_language())) + set_language(config.get('language', self.get_default_language())) Logger.__init__(self) self.logger.info(f"Qml GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}") # Uncomment this call to verify objects are being properly @@ -85,8 +83,8 @@ class ElectrumGui(Logger): QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_X11InitThreads) if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"): QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) -# if hasattr(QGuiApplication, 'setDesktopFileName'): -# QGuiApplication.setDesktopFileName('electrum.desktop') + if hasattr(QGuiApplication, 'setDesktopFileName'): + QGuiApplication.setDesktopFileName('electrum.desktop') if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling); os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" @@ -96,6 +94,11 @@ class ElectrumGui(Logger): self.daemon = daemon self.plugins = plugins self.app = ElectrumQmlApplication(sys.argv, self.daemon) + # timer + self.timer = QTimer(self.app) + self.timer.setSingleShot(False) + self.timer.setInterval(500) # msec + self.timer.timeout.connect(lambda: None) # periodically enter python scope # Initialize any QML plugins run_hook('init_qml', self) @@ -113,10 +116,20 @@ class ElectrumGui(Logger): self.app.quit() def main(self): - if self.app._valid: - self.logger.info('Entering main loop') - self.app.exec_() + if not self.app._valid: + return + + self.timer.start() + signal.signal(signal.SIGINT, lambda *args: self.stop()) + + self.logger.info('Entering main loop') + self.app.exec_() def stop(self): self.logger.info('closing GUI') self.app.quit() + + def get_default_language(self): + name = QLocale.system().name() + return name if name in languages else 'en_UK' + From 3b22ecdae4e4c72799cb666c9deb3027af7503f2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 6 Apr 2021 14:13:51 +0200 Subject: [PATCH 012/218] qml: add available wallets model --- electrum/gui/qml/__init__.py | 7 +- electrum/gui/qml/components/Wallets.qml | 19 +++- electrum/gui/qml/qedaemon.py | 115 ++++++++++++++++++++++++ electrum/gui/qml/qenetwork.py | 63 +------------ 4 files changed, 135 insertions(+), 69 deletions(-) create mode 100644 electrum/gui/qml/qedaemon.py diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 444f0a88b..5d9b2a0f6 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -15,7 +15,7 @@ try: except Exception: sys.exit("Error: Could not import PyQt5.QtQml on Linux systems, you may try 'sudo apt-get install python3-pyqt5.qtquick'") -from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QUrl, QLocale, QTimer +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QLocale, QTimer from PyQt5.QtGui import QGuiApplication from PyQt5.QtQml import qmlRegisterType, QQmlComponent, QQmlApplicationEngine from PyQt5.QtQuick import QQuickView @@ -36,8 +36,9 @@ if TYPE_CHECKING: from electrum.simple_config import SimpleConfig from electrum.plugin import Plugins -from .qenetwork import QENetwork, QEDaemon, QEWalletListModel -from .qewallet import * +from .qedaemon import QEDaemon, QEWalletListModel +from .qenetwork import QENetwork +from .qewallet import QEWallet from .qeqr import QEQR class ElectrumQmlApplication(QGuiApplication): diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 5ce2fc859..7dafba18b 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -5,13 +5,16 @@ import QtQuick.Controls 2.0 Item { property string title: 'Wallets' + anchors.fill: parent + ListView { width: parent.width - height: 200 - model: Daemon.activeWallets + height: parent.height + model: Daemon.availableWallets delegate: Item { width: ListView.view.width + height: 50 RowLayout { x: 20 @@ -22,9 +25,17 @@ Item { } Label { - font.pointSize: 13 - text: model.display + font.pointSize: model.active ? 14 : 13 + font.bold: model.active + text: model.name + Layout.fillWidth: true } + + } + + MouseArea { + anchors.fill: parent + onClicked: openMenu() } } } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py new file mode 100644 index 000000000..58f4ce928 --- /dev/null +++ b/electrum/gui/qml/qedaemon.py @@ -0,0 +1,115 @@ +import os + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.util import register_callback, get_new_wallet_name +from electrum.logging import get_logger +from electrum.wallet import Wallet, Abstract_Wallet + +from .qewallet import QEWallet + +# wallet list model. supports both wallet basenames (wallet file basenames) +# and whole Wallet instances (loaded wallets) +class QEWalletListModel(QAbstractListModel): + _logger = get_logger(__name__) + def __init__(self, parent=None): + QAbstractListModel.__init__(self, parent) + self.wallets = [] + + # define listmodel rolemap + _ROLE_NAMES= ('name','path','active') + _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.wallets) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + role_index = role - (Qt.UserRole + 1) + value = tx[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 data(self, index, role): + (wallet_name, wallet_path, wallet) = self.wallets[index.row()] + role_index = role - (Qt.UserRole + 1) + role_name = self._ROLE_NAMES[role_index] + if role_name == 'name': + return wallet_name + if role_name == 'path': + return wallet_name + if role_name == 'active': + return wallet != None + + def add_wallet(self, wallet_path = None, wallet: Abstract_Wallet = None): + if wallet_path == None and wallet == None: + return + self.beginInsertRows(QModelIndex(), len(self.wallets), len(self.wallets)); + if wallet == None: + wallet_name = os.path.basename(wallet_path) + else: + wallet_name = wallet.basename() + item = (wallet_name, wallet_path, wallet) + self.wallets.append(item); + self.endInsertRows(); + + +class QEDaemon(QObject): + def __init__(self, daemon, parent=None): + super().__init__(parent) + self.daemon = daemon + + _logger = get_logger(__name__) + _loaded_wallets = QEWalletListModel() + + walletLoaded = pyqtSignal() + walletRequiresPassword = pyqtSignal() + + @pyqtSlot() + def load_wallet(self, path=None, password=None): + if path == None: + path = self.daemon.config.get('gui_last_wallet') + wallet = self.daemon.load_wallet(path, password) + if wallet != None: + self._loaded_wallets.add_wallet(wallet=wallet) + self._current_wallet = QEWallet(wallet) + self.walletLoaded.emit() + else: + self._logger.info('fail open wallet') + self.walletRequiresPassword.emit() + + @pyqtProperty(QEWallet,notify=walletLoaded) + def currentWallet(self): + return self._current_wallet + + @pyqtProperty('QString',notify=walletLoaded) + def walletName(self): + return self._current_wallet.wallet.basename() + + @pyqtProperty(QEWalletListModel) + def activeWallets(self): + return self._loaded_wallets + + @pyqtProperty(QEWalletListModel) + def availableWallets(self): + available = [] + availableListModel = QEWalletListModel(self) + wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path()) + with os.scandir(wallet_folder) as it: + for i in it: + if i.is_file(): + available.append(i.path) + for path in sorted(available): + wallet = self.daemon.get_wallet(path) + if wallet != None: + availableListModel.add_wallet(wallet_path = wallet.storage.path, wallet = wallet) + else: + availableListModel.add_wallet(wallet_path = path) + return availableListModel diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index 312021d9c..38a3b6b03 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -1,11 +1,7 @@ -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.util import register_callback from electrum.logging import get_logger -from electrum.wallet import Wallet, Abstract_Wallet - -from .qewallet import QEWallet class QENetwork(QObject): def __init__(self, network, parent=None): @@ -69,60 +65,3 @@ class QENetwork(QObject): def status(self): return self._status -class QEWalletListModel(QAbstractListModel): - def __init__(self, parent=None): - QAbstractListModel.__init__(self, parent) - self.wallets = [] - - def rowCount(self, index): - return len(self.wallets) - - def data(self, index, role): - if role == Qt.DisplayRole: - return self.wallets[index.row()].basename() - - def add_wallet(self, wallet: Abstract_Wallet = None): - if wallet == None: - return - self.beginInsertRows(QModelIndex(), len(self.wallets), len(self.wallets)); - self.wallets.append(wallet); - self.endInsertRows(); - - -class QEDaemon(QObject): - def __init__(self, daemon, parent=None): - super().__init__(parent) - self.daemon = daemon - - _logger = get_logger(__name__) - _wallet = '' - _loaded_wallets = QEWalletListModel() - - wallet_loaded = pyqtSignal() - - @pyqtSlot() - def load_wallet(self, path=None, password=None): - self._logger.info(str(self.daemon.get_wallets())) - if path == None: - path = self.daemon.config.get('recently_open')[0] - wallet = self.daemon.load_wallet(path, password) - if wallet != None: - self._loaded_wallets.add_wallet(wallet) - self._wallet = wallet.basename() - self._current_wallet = QEWallet(wallet) - self.wallet_loaded.emit() - self._logger.info(str(self.daemon.get_wallets())) - else: - self._logger.info('fail open wallet') - - @pyqtProperty('QString',notify=wallet_loaded) - def walletName(self): - return self._wallet - - @pyqtProperty(QEWalletListModel) - def activeWallets(self): - return self._loaded_wallets - - @pyqtProperty(QEWallet,notify=wallet_loaded) - def currentWallet(self): - return self._current_wallet From 6e482f437a6b15e865deab60ad033a1a4a8907f2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 7 Apr 2021 16:48:40 +0200 Subject: [PATCH 013/218] qml: fixes and cleanup for qedaemon, qenetwork. expose many wallet properties in qewallet --- electrum/gui/qml/qedaemon.py | 25 ++++------ electrum/gui/qml/qenetwork.py | 39 +++++++++------ electrum/gui/qml/qewallet.py | 93 ++++++++++++++++++++++++++++++++--- 3 files changed, 120 insertions(+), 37 deletions(-) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 58f4ce928..24a168202 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -28,15 +28,6 @@ class QEWalletListModel(QAbstractListModel): def roleNames(self): return self._ROLE_MAP - def data(self, index, role): - role_index = role - (Qt.UserRole + 1) - value = tx[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 data(self, index, role): (wallet_name, wallet_path, wallet) = self.wallets[index.row()] role_index = role - (Qt.UserRole + 1) @@ -44,7 +35,7 @@ class QEWalletListModel(QAbstractListModel): if role_name == 'name': return wallet_name if role_name == 'path': - return wallet_name + return wallet_path if role_name == 'active': return wallet != None @@ -72,8 +63,13 @@ class QEDaemon(QObject): walletLoaded = pyqtSignal() walletRequiresPassword = pyqtSignal() + _current_wallet = None + @pyqtSlot() + @pyqtSlot(str) + @pyqtSlot(str, str) def load_wallet(self, path=None, password=None): + self._logger.debug('load wallet ' + str(path)) if path == None: path = self.daemon.config.get('gui_last_wallet') wallet = self.daemon.load_wallet(path, password) @@ -91,7 +87,9 @@ class QEDaemon(QObject): @pyqtProperty('QString',notify=walletLoaded) def walletName(self): - return self._current_wallet.wallet.basename() + if self._current_wallet != None: + return self._current_wallet.wallet.basename() + return '' @pyqtProperty(QEWalletListModel) def activeWallets(self): @@ -108,8 +106,5 @@ class QEDaemon(QObject): available.append(i.path) for path in sorted(available): wallet = self.daemon.get_wallet(path) - if wallet != None: - availableListModel.add_wallet(wallet_path = wallet.storage.path, wallet = wallet) - else: - availableListModel.add_wallet(wallet_path = path) + availableListModel.add_wallet(wallet_path = path, wallet = wallet) return availableListModel diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index 38a3b6b03..a857fdf1a 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -2,6 +2,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.util import register_callback from electrum.logging import get_logger +from electrum import constants class QENetwork(QObject): def __init__(self, network, parent=None): @@ -15,11 +16,13 @@ class QENetwork(QObject): _logger = get_logger(__name__) - network_updated = pyqtSignal() - blockchain_updated = pyqtSignal() - default_server_changed = pyqtSignal() - proxy_set = pyqtSignal() - status_updated = pyqtSignal() + networkUpdated = pyqtSignal() + blockchainUpdated = pyqtSignal() + defaultServerChanged = pyqtSignal() + proxySet = pyqtSignal() + statusUpdated = pyqtSignal() + + dataChanged = pyqtSignal() # dummy to silence warnings _num_updates = 0 _server = "" @@ -28,40 +31,48 @@ class QENetwork(QObject): def on_network_updated(self, event, *args): self._num_updates = self._num_updates + 1 - self.network_updated.emit() + self.networkUpdated.emit() def on_blockchain_updated(self, event, *args): self._logger.info('chainupdate: ' + str(event) + str(args)) self._height = self.network.get_local_height() - self.blockchain_updated.emit() + self.blockchainUpdated.emit() def on_default_server_changed(self, event, *args): netparams = self.network.get_parameters() self._server = str(netparams.server) - self.default_server_changed.emit() + self.defaultServerChanged.emit() def on_proxy_set(self, event, *args): self._logger.info('proxy set') - self.proxy_set.emit() + self.proxySet.emit() def on_status(self, event, *args): self._logger.info('status updated') self._status = self.network.connection_status - self.status_updated.emit() + self.statusUpdated.emit() - @pyqtProperty(int,notify=network_updated) + @pyqtProperty(int,notify=networkUpdated) def updates(self): return self._num_updates - @pyqtProperty(int,notify=blockchain_updated) + @pyqtProperty(int,notify=blockchainUpdated) def height(self): return self._height - @pyqtProperty('QString',notify=default_server_changed) + @pyqtProperty('QString',notify=defaultServerChanged) def server(self): return self._server - @pyqtProperty('QString',notify=status_updated) + @pyqtProperty('QString',notify=statusUpdated) def status(self): return self._status + @pyqtProperty(bool, notify=dataChanged) + def isTestNet(self): + return constants.net.TESTNET + + @pyqtProperty('QString', notify=dataChanged) + def networkName(self): + return constants.net.__name__.replace('Bitcoin','') + diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 1aabfcc40..1ded4dc7b 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -4,6 +4,9 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex, QByteArray from electrum.util import register_callback, Satoshis 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 class QETransactionsListModel(QAbstractListModel): def __init__(self, parent=None): @@ -13,11 +16,10 @@ class QETransactionsListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - ROLES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp','incoming','bc_value', + _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp','incoming','bc_value', 'bc_balance','date','label','txpos_in_block','fee','inputs','outputs') - keys = range(Qt.UserRole + 1, Qt.UserRole + 1 + len(ROLES)) - ROLENAMES = [bytearray(x.encode()) for x in ROLES] - _ROLE_MAP = dict(zip(keys, ROLENAMES)) + _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.tx_history) @@ -28,14 +30,21 @@ class QETransactionsListModel(QAbstractListModel): def data(self, index, role): tx = self.tx_history[index.row()] role_index = role - (Qt.UserRole + 1) - value = tx[self.ROLES[role_index]] + value = tx[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.tx_history = [] + self.endResetModel() + + # initial model data def set_history(self, history): + self.clear() self.beginInsertRows(QModelIndex(), 0, len(history) - 1) self.tx_history = history self.tx_history.reverse() @@ -45,23 +54,91 @@ class QEWallet(QObject): def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet + self._historyModel = QETransactionsListModel() self.get_history() + register_callback(self.on_request_status, ['request_status']) _logger = get_logger(__name__) - _historyModel = QETransactionsListModel() + dataChanged = pyqtSignal() # dummy to silence warnings - @pyqtProperty(QETransactionsListModel) + requestStatus = pyqtSignal() + def on_request_status(self, event, *args): + self._logger.debug(str(event)) +# (wallet, addr, status) = args +# self._historyModel.add_tx() + self.requestStatus.emit() + + historyModelChanged = pyqtSignal() + @pyqtProperty(QETransactionsListModel, notify=historyModelChanged) def historyModel(self): return self._historyModel + @pyqtProperty('QString', notify=dataChanged) + def txinType(self): + return self.wallet.get_txin_type(self.wallet.dummy_address()) + + @pyqtProperty(bool, notify=dataChanged) + def isWatchOnly(self): + return self.wallet.is_watching_only() + + @pyqtProperty(bool, notify=dataChanged) + def isDeterministic(self): + return self.wallet.is_deterministic() + + @pyqtProperty(bool, notify=dataChanged) + def isEncrypted(self): + return self.wallet.storage.is_encrypted() + + @pyqtProperty(bool, notify=dataChanged) + def isHardware(self): + return self.wallet.storage.is_encrypted_with_hw_device() + + @pyqtProperty('QString', notify=dataChanged) + def derivationPath(self): + return self.wallet.get_address_path_str(self.wallet.dummy_address()) + + balanceChanged = pyqtSignal() + + @pyqtProperty(int, notify=balanceChanged) + def frozenBalance(self): + return self.wallet.get_frozen_balance() + + @pyqtProperty(int, notify=balanceChanged) + def unconfirmedBalance(self): + return self.wallet.get_balance()[1] + + @pyqtProperty(int, notify=balanceChanged) + def confirmedBalance(self): + c, u, x = self.wallet.get_balance() + self._logger.info('balance: ' + str(c) + ' ' + str(u) + ' ' + str(x) + ' ') + + return c+x + + # lightning feature? + isUptodateChanged = pyqtSignal() + @pyqtProperty(bool, notify=isUptodateChanged) + def isUptodate(self): + return self.wallet.is_up_to_date() + def get_history(self): history = self.wallet.get_detailed_history(show_addresses = True) txs = history['transactions'] - self._logger.info(txs) # use primitives for tx in txs: for output in tx['outputs']: output['value'] = output['value'].value self._historyModel.set_history(txs) + self.historyModelChanged.emit() + + @pyqtSlot('QString', int, int, bool) + def send_onchain(self, address, amount, fee=None, rbf=False): + self._logger.info('send_onchain: ' + address + ' ' + str(amount)) + coins = self.wallet.get_spendable_coins(None) + if not bitcoin.is_address(address): + self._logger.warning('Invalid Bitcoin Address: ' + address) + return False + outputs = [PartialTxOutput.from_address_and_value(address, amount)] + tx = self.wallet.make_unsigned_transaction(coins=coins,outputs=outputs) + return True From ba7bcbfcbc4a04a7d0abef466afe5e1412c4721a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 7 Apr 2021 16:50:34 +0200 Subject: [PATCH 014/218] qml: many UI updates and additions --- .../gui/qml/components/BalanceSummary.qml | 31 +++ electrum/gui/qml/components/History.qml | 226 ++++++++++-------- electrum/gui/qml/components/NetworkStats.qml | 4 +- electrum/gui/qml/components/Scan.qml | 3 +- electrum/gui/qml/components/Send.qml | 43 ++-- electrum/gui/qml/components/Wallets.qml | 106 ++++++-- electrum/gui/qml/components/landing.qml | 92 ++++--- electrum/gui/qml/components/main.qml | 82 ++++++- 8 files changed, 403 insertions(+), 184 deletions(-) create mode 100644 electrum/gui/qml/components/BalanceSummary.qml diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml new file mode 100644 index 000000000..9c452f75b --- /dev/null +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -0,0 +1,31 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 + +Item { + height: layout.height + + GridLayout { + id: layout + + columns: 3 + Label { + Layout.columnSpan: 3 + font.pointSize: 14 + text: 'Balance: ' + Daemon.currentWallet.confirmedBalance //'5.6201 mBTC' + } + Label { + font.pointSize: 8 + text: 'Confirmed: ' + Daemon.currentWallet.confirmedBalance + } + Label { + font.pointSize: 8 + text: 'Unconfirmed: ' + Daemon.currentWallet.unconfirmedBalance + } + Label { + font.pointSize: 8 + text: 'Lightning: ?' + } + } + +} diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index cc7f898c6..4579603df 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -2,132 +2,146 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 -Item { - id: rootItem +import Electrum 1.0 - property string title: 'History' +Pane { + id: rootItem + visible: Daemon.currentWallet !== undefined + clip: true - Column { + ListView { + id: listview width: parent.width + height: parent.height - ListView { - width: parent.width - height: 200 + model: Daemon.currentWallet.historyModel - model: Daemon.currentWallet.historyModel - delegate: Item { - id: delegate - width: ListView.view.width - height: txinfo.height + header: Item { + id: header + width: ListView.view.width + height: balance.height - MouseArea { - anchors.fill: delegate - onClicked: extinfo.visible = !extinfo.visible - } + BalanceSummary { + id: balance + width: parent.width + } - GridLayout { - id: txinfo - columns: 4 - - x: 6 - width: delegate.width - 12 - - Item { - id: indicator - Layout.fillHeight: true - Layout.rowSpan: 2 - Rectangle { - width: 3 - color: model.incoming ? 'green' : 'red' - y: 2 - height: parent.height - 4 - } - } + } - Image { - readonly property variant tx_icons : [ - "../../../gui/icons/unconfirmed.png", - "../../../gui/icons/clock1.png", - "../../../gui/icons/clock2.png", - "../../../gui/icons/clock3.png", - "../../../gui/icons/clock4.png", - "../../../gui/icons/clock5.png", - "../../../gui/icons/confirmed.png" - ] - - sourceSize.width: 32 - sourceSize.height: 32 - Layout.alignment: Qt.AlignVCenter - source: tx_icons[Math.min(6,model.confirmations)] + delegate: Item { + id: delegate + width: ListView.view.width + height: txinfo.height + + MouseArea { + anchors.fill: delegate + onClicked: extinfo.visible = !extinfo.visible + } + + GridLayout { + id: txinfo + columns: 4 + + x: 6 + width: delegate.width - 12 + + Item { + id: indicator + Layout.fillHeight: true + Layout.rowSpan: 2 + Rectangle { + width: 3 + color: model.incoming ? 'green' : 'red' + y: 2 + height: parent.height - 4 } + } - Column { - Layout.fillWidth: true + Image { + readonly property variant tx_icons : [ + "../../../gui/icons/unconfirmed.png", + "../../../gui/icons/clock1.png", + "../../../gui/icons/clock2.png", + "../../../gui/icons/clock3.png", + "../../../gui/icons/clock4.png", + "../../../gui/icons/clock5.png", + "../../../gui/icons/confirmed.png" + ] + + sourceSize.width: 48 + sourceSize.height: 48 + Layout.alignment: Qt.AlignVCenter + source: tx_icons[Math.min(6,model.confirmations)] + } - Label { - text: model.label !== '' ? model.label : '' - color: model.label !== '' ? 'black' : 'gray' - font.bold: model.label !== '' ? true : false - } - Label { - font.pointSize: 7 - text: model.date - } + Column { + Layout.fillWidth: true + + Label { + font.pointSize: 12 + text: model.label !== '' ? model.label : '' + color: model.label !== '' ? 'black' : 'gray' + font.bold: model.label !== '' ? true : false + } + Label { + font.pointSize: 7 + text: model.date } + } - Column { - id: valuefee - Label { - text: model.bc_value - font.bold: true - } - Label { - font.pointSize: 6 - text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') - } + Column { + id: valuefee + Label { + font.pointSize: 12 + text: model.bc_value + font.bold: true + } + Label { + font.pointSize: 6 + text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') } + } - GridLayout { - id: extinfo - visible: false - columns: 2 - Layout.columnSpan: 3 - - Label { text: 'txid' } - Label { - font.pointSize: 6 - text: model.txid - elide: Text.ElideMiddle - Layout.fillWidth: true - } - Label { text: 'height' } - Label { - font.pointSize: 7 - text: model.height - } - Label { text: 'confirmations' } - Label { - font.pointSize: 7 - text: model.confirmations - } - Label { text: 'address' } - Label { - font.pointSize: 7 - elide: Text.ElideMiddle - Layout.fillWidth: true - text: { - for (var i=0; i < Object.keys(model.outputs).length; i++) { - if (model.outputs[i].value === model.bc_value) { - return model.outputs[i].address - } + GridLayout { + id: extinfo + visible: false + columns: 2 + Layout.columnSpan: 3 + + Label { text: 'txid' } + Label { + font.pointSize: 6 + text: model.txid + elide: Text.ElideMiddle + Layout.fillWidth: true + } + Label { text: 'height' } + Label { + font.pointSize: 7 + text: model.height + } + Label { text: 'confirmations' } + Label { + font.pointSize: 7 + text: model.confirmations + } + Label { text: 'address' } + Label { + font.pointSize: 7 + elide: Text.ElideMiddle + Layout.fillWidth: true + text: { + for (var i=0; i < Object.keys(model.outputs).length; i++) { + if (model.outputs[i].value === model.bc_value) { + return model.outputs[i].address } } } } - } - } // delegate - } + + } + } // delegate } diff --git a/electrum/gui/qml/components/NetworkStats.qml b/electrum/gui/qml/components/NetworkStats.qml index ef650cd1e..dff5f391d 100644 --- a/electrum/gui/qml/components/NetworkStats.qml +++ b/electrum/gui/qml/components/NetworkStats.qml @@ -3,12 +3,14 @@ import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 import QtQuick.Controls.Material 2.0 -Item { +Pane { property string title: qsTr('Network') GridLayout { columns: 2 + Label { text: qsTr("Network: "); color: Material.primaryHighlightedTextColor; font.bold: true } + Label { text: Network.networkName } Label { text: qsTr("Server: "); color: Material.primaryHighlightedTextColor; font.bold: true } Label { text: Network.server } Label { text: qsTr("Local Height: "); color: Material.primaryHighlightedTextColor; font.bold: true } diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml index 61b8d179a..734e7d6a5 100644 --- a/electrum/gui/qml/components/Scan.qml +++ b/electrum/gui/qml/components/Scan.qml @@ -4,11 +4,10 @@ import QtQuick.Controls 2.0 Item { property bool toolbar: false - property string title: 'scan' QRScan { anchors.top: parent.top - anchors.bottom: button.top + anchors.bottom: parent.bottom width: parent.width } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 81e27890f..e7f033031 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -2,20 +2,16 @@ import QtQuick 2.6 import QtQuick.Controls 2.0 import QtQuick.Layouts 1.0 -Item { +Pane { id: rootItem - property string title: 'Send' - GridLayout { - width: rootItem.width - 12 - anchors.horizontalCenter: parent.horizontalCenter + width: parent.width columns: 4 - Label { + BalanceSummary { Layout.columnSpan: 4 - Layout.alignment: Qt.AlignHCenter - text: "Current Balance: 0 mBTC" + //Layout.alignment: Qt.AlignHCenter } Label { @@ -47,20 +43,31 @@ Item { placeholderText: 'sat/vB' } - Column { + Item { Layout.fillWidth: true Layout.columnSpan: 4 - Button { + Row { + spacing: 10 anchors.horizontalCenter: parent.horizontalCenter - text: 'Pay' - onClicked: { - var i_amount = parseInt(amount.text) - if (isNaN(i_amount)) - return - var result = Daemon.currentWallet.send_onchain(address.text, i_amount, undefined, false) - if (result) - app.stack.pop() + Button { +// anchors.horizontalCenter: parent.horizontalCenter + text: 'Pay' + enabled: address.text != '' && amount.text != '' && fee.text != '' // TODO proper validation + onClicked: { + var i_amount = parseInt(amount.text) + if (isNaN(i_amount)) + return + var result = Daemon.currentWallet.send_onchain(address.text, i_amount, undefined, false) + if (result) + app.stack.pop() + } + } + + Button { + text: 'Scan QR Code' + Layout.alignment: Qt.AlignHCenter + onClicked: app.stack.push(Qt.resolvedUrl('Scan.qml')) } } } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 7dafba18b..6fa0195de 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -2,42 +2,104 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 -Item { - property string title: 'Wallets' +Pane { + id: rootItem - anchors.fill: parent + property string title: 'Wallets' - ListView { + ColumnLayout { + id: layout width: parent.width height: parent.height - model: Daemon.availableWallets - delegate: Item { - width: ListView.view.width - height: 50 + Item { + width: parent.width + height: detailsLayout.height + + + GridLayout { + id: detailsLayout + width: parent.width + columns: 4 + + Label { text: 'Wallet'; Layout.columnSpan: 2 } + Label { text: Daemon.walletName; Layout.columnSpan: 2 } + + Label { text: 'txinType' } + Label { text: Daemon.currentWallet.txinType } + + Label { text: 'is deterministic' } + Label { text: Daemon.currentWallet.isDeterministic } + + Label { text: 'is watch only' } + Label { text: Daemon.currentWallet.isWatchOnly } - RowLayout { - x: 20 - spacing: 20 + Label { text: 'is Encrypted' } + Label { text: Daemon.currentWallet.isEncrypted } - Image { - source: "../../../gui/kivy/theming/light/wallet.png" + Label { text: 'is Hardware' } + Label { text: Daemon.currentWallet.isHardware } + + Label { text: 'derivation path (BIP32)'; visible: Daemon.currentWallet.isDeterministic } + Label { text: Daemon.currentWallet.derivationPath; visible: Daemon.currentWallet.isDeterministic } } + } +// } + + Item { + width: parent.width +// height: detailsFrame.height + Layout.fillHeight: true + Frame { + id: detailsFrame + width: parent.width + height: parent.height + + ListView { + id: listview + width: parent.width +// Layout.fillHeight: true + height: parent.height + clip:true + model: Daemon.availableWallets - Label { - font.pointSize: model.active ? 14 : 13 - font.bold: model.active - text: model.name - Layout.fillWidth: true + // header: sadly seems to be buggy + + delegate: AbstractButton { + width: ListView.view.width + height: 50 + onClicked: console.log('delegate clicked') + RowLayout { + x: 20 + spacing: 20 + + Image { + source: "../../../gui/kivy/theming/light/wallet.png" + } + + Label { + font.pointSize: 12 + text: model.name + Layout.fillWidth: true + } + Button { + text: 'Load' + onClicked: { + Daemon.load_wallet(model.path, null) + } + } } } + }}} - MouseArea { - anchors.fill: parent - onClicked: openMenu() + Button { + Layout.alignment: Qt.AlignHCenter + text: 'Create Wallet' + onClicked: { + var dialog = app.newWalletWizard.createObject(rootItem) + dialog.open() } } } - } diff --git a/electrum/gui/qml/components/landing.qml b/electrum/gui/qml/components/landing.qml index 55c32c145..302d586a1 100644 --- a/electrum/gui/qml/components/landing.qml +++ b/electrum/gui/qml/components/landing.qml @@ -1,59 +1,87 @@ import QtQuick 2.6 import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.0 import QtQml 2.6 Item { id: rootItem - property string title: 'Network' + property string title: Daemon.walletName property QtObject menu: Menu { MenuItem { text: 'Wallets'; onTriggered: stack.push(Qt.resolvedUrl('Wallets.qml')) } MenuItem { text: 'Network'; onTriggered: stack.push(Qt.resolvedUrl('NetworkStats.qml')) } } - Column { - width: parent.width + ColumnLayout { + anchors.fill: parent - Button { - text: 'Scan QR Code' - onClicked: app.stack.push(Qt.resolvedUrl('Scan.qml')) + TabBar { + id: tabbar + Layout.fillWidth: true + currentIndex: swipeview.currentIndex + TabButton { + text: qsTr('Receive') + } + TabButton { + text: qsTr('History') + } + TabButton { + enabled: !Daemon.currentWallet.isWatchOnly + text: qsTr('Send') + } } - Button { - text: 'Send' - onClicked: app.stack.push(Qt.resolvedUrl('Send.qml')) - } + SwipeView { + id: swipeview - Button { - text: 'Show TX History' - onClicked: app.stack.push(Qt.resolvedUrl('History.qml')) - } + Layout.fillHeight: true + Layout.fillWidth: true + currentIndex: tabbar.currentIndex + + Item { + + ColumnLayout { + width: parent.width + y: 20 + spacing: 20 + + Button { + onClicked: stack.push(Qt.resolvedUrl('Wallets.qml')) + text: 'Wallets' + Layout.alignment: Qt.AlignHCenter + } - Button { - text: 'Create Wallet' - onClicked: { - var dialog = newWalletWizard.createObject(rootItem) - dialog.open() + Button { + text: 'Create Wallet' + Layout.alignment: Qt.AlignHCenter + onClicked: { + var dialog = app.newWalletWizard.createObject(rootItem) + dialog.open() + } + } + + } } - } - } + Item { + History { + id: history + anchors.fill: parent + } + } - Component { - id: newWalletWizard - NewWalletWizard { - parent: Overlay.overlay - x: 12 - y: 12 - width: parent.width - 24 - height: parent.height - 24 - - Overlay.modal: Rectangle { - color: "#aa000000" + + Item { + enabled: !Daemon.currentWallet.isWatchOnly + Send { + anchors.fill: parent + } } } + } + } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index bcbaeb002..b23cd8b26 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -1,5 +1,5 @@ import QtQuick 2.6 -import QtQuick.Controls 2.0 +import QtQuick.Controls 2.3 import QtQuick.Layouts 1.0 import QtQuick.Controls.Material 2.0 @@ -25,15 +25,50 @@ ApplicationWindow anchors.fill: parent ToolButton { text: qsTr("‹") - enabled: stack.currentItem.StackView.index > 0 + enabled: stack.depth > 1 onClicked: stack.pop() } + Item { + width: column.width + height: column.height + MouseArea { + anchors.fill: parent + onClicked: { + var dialog = app.messageDialog.createObject(app, {'message': + 'Electrum is currently on ' + Network.networkName + '' + }) + dialog.open() + } + + } + + Column { + id: column + visible: Network.isTestNet + Image { + anchors.horizontalCenter: parent.horizontalCenter + width: 16 + height: 16 + source: "../../icons/info.png" + } + + Label { + id: networkNameLabel + text: Network.networkName + color: Material.accentColor //'orange' + font.pointSize: 5 + } + } + } + Label { text: stack.currentItem.title elide: Label.ElideRight horizontalAlignment: Qt.AlignHCenter verticalAlignment: Qt.AlignVCenter Layout.fillWidth: true + font.pointSize: 10 + font.bold: true } ToolButton { text: qsTr("⋮") @@ -55,7 +90,7 @@ ApplicationWindow Timer { id: splashTimer - interval: 1000 + interval: 10 onTriggered: { splash.opacity = 0 } @@ -73,8 +108,49 @@ ApplicationWindow } } + property alias newWalletWizard: _newWalletWizard + Component { + id: _newWalletWizard + NewWalletWizard { + parent: Overlay.overlay + x: 12 + y: 12 + width: parent.width - 24 + height: parent.height - 24 + + Overlay.modal: Rectangle { + color: "#aa000000" + } + } + } + + property alias messageDialog: _messageDialog + Component { + id: _messageDialog + Dialog { + parent: Overlay.overlay + modal: true + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + + title: "Message" + property alias message: messageLabel.text + Label { + id: messageLabel + text: "Lorem ipsum dolor sit amet..." + } + + } + } + Component.onCompleted: { Daemon.load_wallet() splashTimer.start() } + + onClosing: { + // destroy most GUI components so that we don't dump so many null reference warnings on exit + app.header.visible = false + mainStackView.clear() + } } From f8ce681f5e1b797ba3ce5660ecb77e121af4bc13 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 10 Sep 2021 12:04:36 +0200 Subject: [PATCH 015/218] allow override of QT_QUICK_CONTROLS_STYLE --- electrum/gui/qml/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 5d9b2a0f6..3e2fe70bf 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -88,7 +88,9 @@ class ElectrumGui(Logger): QGuiApplication.setDesktopFileName('electrum.desktop') if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling); - os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + + if not "QT_QUICK_CONTROLS_STYLE" in os.environ: + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" self.gui_thread = threading.current_thread() self.config = config From 64de9807ac737bb620defd945d406bf818c3cc1f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Nov 2021 13:24:27 +0100 Subject: [PATCH 016/218] remove kivy platform check --- electrum/logging.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/electrum/logging.py b/electrum/logging.py index 316a5c83d..bf5d8caae 100644 --- a/electrum/logging.py +++ b/electrum/logging.py @@ -353,9 +353,9 @@ def get_logfile_path() -> Optional[pathlib.Path]: def describe_os_version() -> str: if 'ANDROID_DATA' in os.environ: - from kivy import utils - if utils.platform != "android": - return utils.platform + #from kivy import utils + #if utils.platform != "android": + # return utils.platform import jnius bv = jnius.autoclass('android.os.Build$VERSION') b = jnius.autoclass('android.os.Build') From 279c1ce9fb5ad1b4466212c97eb19942f378aa2a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Nov 2021 13:25:08 +0100 Subject: [PATCH 017/218] dev: disable PIL use for now --- electrum/gui/qml/qeqr.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index dd57423eb..cdbe914c4 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -2,7 +2,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from electrum.logging import get_logger -from PIL import Image +#from PIL import Image from ctypes import * class QEQR(QObject): @@ -34,7 +34,7 @@ class QEQR(QObject): self._logger.info('depth: ' + str(image.depth())) self._logger.info('format: ' + str(image.format())) - def convertToPILImage(self, image) -> Image: + def convertToPILImage(self, image): # -> Image: self.logImageStats(image) rawimage = image.constBits() @@ -47,7 +47,7 @@ class QEQR(QObject): memmove(c_buf, c_void_p(rawimage.__int__()), numbytes) buf2 = bytes(buf) - return Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') + return None #Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') def parseQR(self, image): # TODO From 18b10c84ca7de4cbc0fbb91dab97f49f78e4b713 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Nov 2021 13:25:34 +0100 Subject: [PATCH 018/218] qml: add debug tracing of QML plugin loading --- electrum/gui/qml/__init__.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 3e2fe70bf..3ca01d4dc 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -29,7 +29,7 @@ from electrum.util import (UserCancelled, profiler, WalletFileException, BitcoinException, get_new_wallet_name) from electrum.wallet import Wallet, Abstract_Wallet from electrum.wallet_db import WalletDB -from electrum.logging import Logger +from electrum.logging import Logger, get_logger if TYPE_CHECKING: from electrum.daemon import Daemon @@ -45,10 +45,22 @@ class ElectrumQmlApplication(QGuiApplication): def __init__(self, args, daemon): super().__init__(args) + self.logger = get_logger(__name__) + qmlRegisterType(QEWalletListModel, 'Electrum', 1, 0, 'WalletListModel') qmlRegisterType(QEWallet, 'Electrum', 1, 0, 'Wallet') self.engine = QQmlApplicationEngine(parent=self) + self.engine.addImportPath('./qml') + + self.logger.info('importPathList() :') + for i in self.engine.importPathList(): + self.logger.info(i) + + self.logger.info('pluginPathList() :') + for i in self.engine.pluginPathList(): + self.logger.info(i) + self.context = self.engine.rootContext() self._singletons['network'] = QENetwork(daemon.network) self._singletons['daemon'] = QEDaemon(daemon) @@ -76,7 +88,11 @@ class ElectrumGui(Logger): def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): set_language(config.get('language', self.get_default_language())) Logger.__init__(self) + os.environ['QML_IMPORT_TRACE'] = '1' + os.environ['QT_DEBUG_PLUGINS'] = '1' + self.logger.info(f"Qml GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}") + self.logger.info("CWD=%s" % os.getcwd()) # Uncomment this call to verify objects are being properly # GC-ed when windows are closed #network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer, From c3d37913cdff5a43fd58dbb39cdd4c6c039ce103 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Nov 2021 13:26:13 +0100 Subject: [PATCH 019/218] android now uses qml UI --- run_electrum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_electrum b/run_electrum index 96da77c66..5b403977b 100755 --- a/run_electrum +++ b/run_electrum @@ -325,9 +325,9 @@ def main(): from jnius import autoclass build_config = autoclass("org.electrum.electrum.BuildConfig") config_options = { - 'verbosity': '*' if build_config.DEBUG else '', + 'verbosity': '*', #if build_config.DEBUG else '', 'cmd': 'gui', - 'gui': 'kivy', + 'gui': 'qml', 'single_password':True, } else: From 56bbd28af7ad8b69124de66ed77edffd8e5e86b8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 29 Nov 2021 16:27:33 +0100 Subject: [PATCH 020/218] qml: generalize Wizard --- .../gui/qml/components/NewWalletWizard.qml | 125 ++--------------- electrum/gui/qml/components/Wizard.qml | 127 ++++++++++++++++++ .../gui/qml/components/WizardComponent.qml | 1 + .../gui/qml/components/WizardComponents.qml | 47 ++++--- 4 files changed, 167 insertions(+), 133 deletions(-) create mode 100644 electrum/gui/qml/components/Wizard.qml diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index 3eec5b7b8..781aca127 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -2,66 +2,31 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 -Dialog { +Wizard { id: walletwizard title: qsTr('New Wallet') - modal: true enter: null // disable transition - property var wizard_data - - function _setWizardData(wdata) { - wizard_data = {} - Object.assign(wizard_data, wdata) // deep copy - console.log('wizard data is now :' + JSON.stringify(wizard_data)) - } - - // helper function to dynamically load wizard page components - // and add them to the SwipeView - // Here we do some manual binding of page.valid -> pages.pagevalid - // to propagate the state without the binding going stale - function _loadNextComponent(comp, wdata={}) { - var page = comp.createObject(pages, { - 'visible': Qt.binding(function() { - return pages.currentItem === this - }) - }) - page.validChanged.connect(function() { - pages.pagevalid = page.valid - } ) - page.lastChanged.connect(function() { - pages.lastpage = page.last - } ) - Object.assign(page.wizard_data, wdata) // deep copy - pages.pagevalid = page.valid - - return page - } - // State transition functions. These functions are called when the 'Next' - // button is pressed. They take data from the component, add it to the - // wizard_data object, and depending on the data create the next page + // button is pressed. Depending on the data create the next page // in the conversation. function walletnameDone(d) { console.log('wallet name done') - wizard_data['wallet_name'] = pages.currentItem.wallet_name var page = _loadNextComponent(components.wallettype, wizard_data) page.next.connect(function() {wallettypeDone()}) } function wallettypeDone(d) { console.log('wallet type done') - wizard_data['wallet_type'] = pages.currentItem.wallet_type var page = _loadNextComponent(components.keystore, wizard_data) page.next.connect(function() {keystoretypeDone()}) } function keystoretypeDone(d) { console.log('keystore type done') - wizard_data['keystore_type'] = pages.currentItem.keystore_type var page switch(wizard_data['keystore_type']) { case 'createseed': @@ -79,7 +44,6 @@ Dialog { function createseedDone(d) { console.log('create seed done') - wizard_data['seed'] = pages.currentItem.seed var page = _loadNextComponent(components.confirmseed, wizard_data) page.next.connect(function() {confirmseedDone()}) } @@ -93,7 +57,6 @@ Dialog { function haveseedDone(d) { console.log('have seed done') - wizard_data['seed'] = pages.currentItem.seed var page = _loadNextComponent(components.walletpassword, wizard_data) page.next.connect(function() {walletpasswordDone()}) page.last = true @@ -101,90 +64,18 @@ Dialog { function walletpasswordDone(d) { console.log('walletpassword done') - wizard_data['password'] = pages.currentItem.password - wizard_data['encrypt'] = pages.currentItem.encrypt var page = _loadNextComponent(components.walletpassword, wizard_data) } - - ColumnLayout { - anchors.fill: parent - - SwipeView { - id: pages - Layout.fillHeight: true - interactive: false - - function prev() { - currentIndex = currentIndex - 1 - _setWizardData(pages.contentChildren[currentIndex].wizard_data) - pages.pagevalid = pages.contentChildren[currentIndex].valid - pages.contentChildren[currentIndex+1].destroy() - } - - function next() { - currentItem.next() - currentIndex = currentIndex + 1 - } - - function finalize() { - walletwizard.accept() - } - - property bool pagevalid: false - property bool lastpage: false - - Component.onCompleted: { - _setWizardData({}) - var start = _loadNextComponent(components.walletname) - start.next.connect(function() {walletnameDone()}) - } - - } - - PageIndicator { - id: indicator - - Layout.alignment: Qt.AlignHCenter - - count: pages.count - currentIndex: pages.currentIndex - } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - Button { - visible: pages.currentIndex == 0 - text: qsTr("Cancel") - onClicked: walletwizard.close() - } - - Button { - visible: pages.currentIndex > 0 - text: qsTr('Back') - onClicked: pages.prev() - } - - Button { - text: "Next" - visible: !pages.lastpage - enabled: pages.pagevalid - onClicked: pages.next() - } - - Button { - text: "Create" - visible: pages.lastpage - enabled: pages.pagevalid - onClicked: pages.finalize() - } - - } - } - WizardComponents { id: components } + Component.onCompleted: { + _setWizardData({}) + var start = _loadNextComponent(components.walletname) + start.next.connect(function() {walletnameDone()}) + } + } diff --git a/electrum/gui/qml/components/Wizard.qml b/electrum/gui/qml/components/Wizard.qml new file mode 100644 index 000000000..b166297f7 --- /dev/null +++ b/electrum/gui/qml/components/Wizard.qml @@ -0,0 +1,127 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +Dialog { + id: wizard + modal: true + + width: parent.width + height: parent.height + + property var wizard_data + property alias pages : pages + + function _setWizardData(wdata) { + wizard_data = {} + Object.assign(wizard_data, wdata) // deep copy + console.log('wizard data is now :' + JSON.stringify(wizard_data)) + } + + // helper function to dynamically load wizard page components + // and add them to the SwipeView + // Here we do some manual binding of page.valid -> pages.pagevalid and + // page.last -> pages.lastpage to propagate the state without the binding + // going stale. + function _loadNextComponent(comp, wdata={}) { + var page = comp.createObject(pages, { + 'visible': Qt.binding(function() { + return pages.currentItem === this + }) + }) + page.validChanged.connect(function() { + pages.pagevalid = page.valid + } ) + page.lastChanged.connect(function() { + pages.lastpage = page.last + } ) + Object.assign(page.wizard_data, wdata) // deep copy + pages.pagevalid = page.valid + pages.lastpage = page.last + + return page + } + + ColumnLayout { + anchors.fill: parent + + SwipeView { + id: pages + Layout.fillWidth: true + interactive: false + + function prev() { + currentIndex = currentIndex - 1 + _setWizardData(pages.contentChildren[currentIndex].wizard_data) + pages.pagevalid = pages.contentChildren[currentIndex].valid + pages.lastpage = pages.contentChildren[currentIndex].last + pages.contentChildren[currentIndex+1].destroy() + } + + function next() { + currentItem.accept() + _setWizardData(pages.contentChildren[currentIndex].wizard_data) + currentItem.next() + currentIndex = currentIndex + 1 + } + + function finish() { + currentItem.accept() + _setWizardData(pages.contentChildren[currentIndex].wizard_data) + wizard.accept() + } + + property bool pagevalid: false + property bool lastpage: false + + Component.onCompleted: { + _setWizardData({}) + } + + } + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom + + PageIndicator { + id: indicator + + Layout.alignment: Qt.AlignHCenter + + count: pages.count + currentIndex: pages.currentIndex + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Button { + visible: pages.currentIndex == 0 + text: qsTr("Cancel") + onClicked: wizard.reject() + } + + Button { + visible: pages.currentIndex > 0 + text: qsTr('Back') + onClicked: pages.prev() + } + + Button { + text: qsTr("Next") + visible: !pages.lastpage + enabled: pages.pagevalid + onClicked: pages.next() + } + + Button { + text: qsTr("Finish") + visible: pages.lastpage + enabled: pages.pagevalid + onClicked: pages.finish() + } + + } + } + } + +} diff --git a/electrum/gui/qml/components/WizardComponent.qml b/electrum/gui/qml/components/WizardComponent.qml index a07885259..34800b272 100644 --- a/electrum/gui/qml/components/WizardComponent.qml +++ b/electrum/gui/qml/components/WizardComponent.qml @@ -2,6 +2,7 @@ import QtQuick 2.0 Item { signal next + signal accept property var wizard_data : ({}) property bool valid property bool last: false diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index 6bacca682..cccc1fbad 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -6,7 +6,11 @@ Item { property Component walletname: Component { WizardComponent { valid: wallet_name.text.length > 0 - property alias wallet_name: wallet_name.text + //property alias wallet_name: wallet_name.text + onAccept: { + wizard_data['wallet_name'] = wallet_name.text + } + GridLayout { columns: 1 Label { text: qsTr('Wallet name') } @@ -20,13 +24,13 @@ Item { property Component wallettype: Component { WizardComponent { valid: wallettypegroup.checkedButton !== null - property string wallet_type + + onAccept: { + wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype + } ButtonGroup { id: wallettypegroup - onCheckedButtonChanged: { - wallet_type = checkedButton.wallettype - } } GridLayout { @@ -63,13 +67,13 @@ Item { property Component keystore: Component { WizardComponent { valid: keystoregroup.checkedButton !== null - property string keystore_type + + onAccept: { + wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype + } ButtonGroup { id: keystoregroup - onCheckedButtonChanged: { - keystore_type = checkedButton.keystoretype - } } GridLayout { @@ -106,8 +110,12 @@ Item { property Component createseed: Component { WizardComponent { valid: true - property alias seed: seedtext.text - property alias extend: extendcb.checked + + onAccept: { + wizard_data['seed'] = seedtext.text + wizard_data['seed_extend'] = extendcb.checked + } + GridLayout { columns: 1 Label { text: qsTr('Generating seed') } @@ -129,9 +137,13 @@ Item { property Component haveseed: Component { WizardComponent { valid: true - property alias seed: seedtext.text - property alias extend: extendcb.checked - property alias bip39: bip39cb.checked + + onAccept: { + wizard_data['seed'] = seedtext.text + wizard_data['seed_extend'] = extendcb.checked + wizard_data['seed_bip39'] = bip39cb.checked + } + GridLayout { columns: 1 Label { text: qsTr('Enter your seed') } @@ -179,8 +191,11 @@ Item { WizardComponent { valid: password1.text === password2.text - property alias password: password1.text - property alias encrypt: doencrypt.checked + onAccept: { + wizard_data['password'] = password1.text + wizard_data['encrypt'] = doencrypt.checked + } + GridLayout { columns: 1 Label { text: qsTr('Password protect wallet?') } From d13f5d0da0e4fddf2dff1565379df5ae727c8104 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Dec 2021 01:02:52 +0100 Subject: [PATCH 021/218] qml: add server connect wizard --- electrum/gui/qml/__init__.py | 40 ++-- electrum/gui/qml/components/History.qml | 2 +- .../qml/components/ServerConnectWizard.qml | 223 ++++++++++++++++++ .../{landing.qml => WalletMainView.qml} | 0 .../gui/qml/components/WizardComponents.qml | 2 +- electrum/gui/qml/components/main.qml | 35 ++- electrum/gui/qml/qeconfig.py | 47 ++++ electrum/gui/qml/qenetwork.py | 26 ++ 8 files changed, 345 insertions(+), 30 deletions(-) create mode 100644 electrum/gui/qml/components/ServerConnectWizard.qml rename electrum/gui/qml/components/{landing.qml => WalletMainView.qml} (100%) create mode 100644 electrum/gui/qml/qeconfig.py diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 3ca01d4dc..42a4ee40c 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -15,7 +15,7 @@ try: except Exception: sys.exit("Error: Could not import PyQt5.QtQml on Linux systems, you may try 'sudo apt-get install python3-pyqt5.qtquick'") -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QLocale, QTimer +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QLocale, QTimer, qInstallMessageHandler from PyQt5.QtGui import QGuiApplication from PyQt5.QtQml import qmlRegisterType, QQmlComponent, QQmlApplicationEngine from PyQt5.QtQuick import QQuickView @@ -36,39 +36,36 @@ if TYPE_CHECKING: from electrum.simple_config import SimpleConfig from electrum.plugin import Plugins +from .qeconfig import QEConfig from .qedaemon import QEDaemon, QEWalletListModel from .qenetwork import QENetwork from .qewallet import QEWallet from .qeqr import QEQR class ElectrumQmlApplication(QGuiApplication): - def __init__(self, args, daemon): + def __init__(self, args, config, daemon): super().__init__(args) - self.logger = get_logger(__name__) + self.logger = get_logger(__name__ + '.engine') - qmlRegisterType(QEWalletListModel, 'Electrum', 1, 0, 'WalletListModel') - qmlRegisterType(QEWallet, 'Electrum', 1, 0, 'Wallet') + qmlRegisterType(QEWalletListModel, 'org.electrum', 1, 0, 'WalletListModel') + qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet') self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') - self.logger.info('importPathList() :') - for i in self.engine.importPathList(): - self.logger.info(i) - - self.logger.info('pluginPathList() :') - for i in self.engine.pluginPathList(): - self.logger.info(i) - self.context = self.engine.rootContext() + self._singletons['config'] = QEConfig(config) self._singletons['network'] = QENetwork(daemon.network) self._singletons['daemon'] = QEDaemon(daemon) self._singletons['qr'] = QEQR() + self.context.setContextProperty('Config', self._singletons['config']) self.context.setContextProperty('Network', self._singletons['network']) self.context.setContextProperty('Daemon', self._singletons['daemon']) self.context.setContextProperty('QR', self._singletons['qr']) + qInstallMessageHandler(self.message_handler) + # get notified whether root QML document loads or not self.engine.objectCreated.connect(self.objectCreated) @@ -82,14 +79,17 @@ class ElectrumQmlApplication(QGuiApplication): self._valid = False self.engine.objectCreated.disconnect(self.objectCreated) + def message_handler(self, line, funct, file): + self.logger.warning(file) + class ElectrumGui(Logger): @profiler def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): set_language(config.get('language', self.get_default_language())) Logger.__init__(self) - os.environ['QML_IMPORT_TRACE'] = '1' - os.environ['QT_DEBUG_PLUGINS'] = '1' + #os.environ['QML_IMPORT_TRACE'] = '1' + #os.environ['QT_DEBUG_PLUGINS'] = '1' self.logger.info(f"Qml GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}") self.logger.info("CWD=%s" % os.getcwd()) @@ -112,7 +112,7 @@ class ElectrumGui(Logger): self.config = config self.daemon = daemon self.plugins = plugins - self.app = ElectrumQmlApplication(sys.argv, self.daemon) + self.app = ElectrumQmlApplication(sys.argv, self.config, self.daemon) # timer self.timer = QTimer(self.app) self.timer.setSingleShot(False) @@ -124,14 +124,6 @@ class ElectrumGui(Logger): self.app.engine.load('electrum/gui/qml/components/main.qml') def close(self): -# for window in self.windows: -# window.close() -# if self.network_dialog: -# self.network_dialog.close() -# if self.lightning_dialog: -# self.lightning_dialog.close() -# if self.watchtower_dialog: -# self.watchtower_dialog.close() self.app.quit() def main(self): diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 4579603df..625b13c48 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -2,7 +2,7 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 -import Electrum 1.0 +import org.electrum 1.0 Pane { id: rootItem diff --git a/electrum/gui/qml/components/ServerConnectWizard.qml b/electrum/gui/qml/components/ServerConnectWizard.qml new file mode 100644 index 000000000..5562b56e8 --- /dev/null +++ b/electrum/gui/qml/components/ServerConnectWizard.qml @@ -0,0 +1,223 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 + +Wizard { + id: serverconnectwizard + + title: qsTr('How do you want to connect to a server?') + + enter: null // disable transition + + onAccepted: { + var proxy = wizard_data['proxy'] + if (proxy && proxy['enabled'] == true) { + Network.proxy = proxy + } else { + Network.proxy = {'enabled': false} + } + Config.autoConnect = wizard_data['autoconnect'] + if (!wizard_data['autoconnect']) { + Network.server = wizard_data['server'] + } + } + + Component.onCompleted: { + var start = _loadNextComponent(autoconnect) + start.next.connect(function() {autoconnectDone()}) + } + + function autoconnectDone() { + var page = _loadNextComponent(proxyconfig, wizard_data) + page.next.connect(function() {proxyconfigDone()}) + } + + function proxyconfigDone() { + var page = _loadNextComponent(serverconfig, wizard_data) + } + + property Component autoconnect: Component { + WizardComponent { + valid: true + last: serverconnectgroup.checkedButton.connecttype === 'auto' + + onAccept: { + wizard_data['autoconnect'] = serverconnectgroup.checkedButton.connecttype === 'auto' + } + + ColumnLayout { + anchors.fill: parent + + Text { + text: qsTr('Electrum communicates with remote servers to get information about your transactions and addresses. The servers all fulfill the same purpose only differing in hardware. In most cases you simply want to let Electrum pick one at random. However if you prefer feel free to select a server manually.') + wrapMode: Text.Wrap + Layout.fillWidth: true + color: Material.primaryTextColor + } + + ButtonGroup { + id: serverconnectgroup + } + + RadioButton { + ButtonGroup.group: serverconnectgroup + property string connecttype: 'auto' + text: qsTr('Auto connect') + } + RadioButton { + ButtonGroup.group: serverconnectgroup + property string connecttype: 'manual' + checked: true + text: qsTr('Select servers manually') + } + + } + + } + } + + property Component proxyconfig: Component { + WizardComponent { + valid: true + last: false + + onAccept: { + var p = {} + p['enabled'] = proxy_enabled.checked + if (proxy_enabled.checked) { + var type = proxytype.currentValue.toLowerCase() + if (type == 'tor') + type = 'socks5' + p['mode'] = type + p['host'] = address.text + p['port'] = port.text + p['user'] = username.text + p['password'] = password.text + } + wizard_data['proxy'] = p + } + + ColumnLayout { + anchors.fill: parent + + Text { + text: qsTr('Proxy settings') + wrapMode: Text.Wrap + Layout.fillWidth: true + color: Material.primaryTextColor + } + + CheckBox { + id: proxy_enabled + text: qsTr('Enable Proxy') + } + + ComboBox { + id: proxytype + enabled: proxy_enabled.checked + model: ['TOR', 'SOCKS5', 'SOCKS4'] + onCurrentIndexChanged: { + if (currentIndex == 0) { + address.text = "127.0.0.1" + port.text = "9050" + } + } + } + + GridLayout { + columns: 4 + Layout.fillWidth: true + + Label { + text: qsTr("Address") + enabled: address.enabled + } + + TextField { + id: address + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Port") + enabled: port.enabled + } + + TextField { + id: port + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Username") + enabled: username.enabled + } + + TextField { + id: username + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Password") + enabled: password.enabled + } + + TextField { + id: password + enabled: proxytype.enabled && proxytype.currentIndex > 0 + echoMode: TextInput.Password + } + } + } + + } + } + + property Component serverconfig: Component { + WizardComponent { + valid: true + last: true + + onAccept: { + wizard_data['oneserver'] = !auto_server.checked + wizard_data['server'] = address.text + } + + ColumnLayout { + anchors.fill: parent + + Text { + text: qsTr('Server settings') + wrapMode: Text.Wrap + Layout.fillWidth: true + color: Material.primaryTextColor + } + + CheckBox { + id: auto_server + text: qsTr('Select server automatically') + checked: true + } + + GridLayout { + columns: 2 + Layout.fillWidth: true + + Label { + text: qsTr("Server") + enabled: address.enabled + } + + TextField { + id: address + enabled: !auto_server.checked + } + } + } + + } + } + +} diff --git a/electrum/gui/qml/components/landing.qml b/electrum/gui/qml/components/WalletMainView.qml similarity index 100% rename from electrum/gui/qml/components/landing.qml rename to electrum/gui/qml/components/WalletMainView.qml diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index cccc1fbad..6e22726fd 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -6,7 +6,7 @@ Item { property Component walletname: Component { WizardComponent { valid: wallet_name.text.length > 0 - //property alias wallet_name: wallet_name.text + onAccept: { wizard_data['wallet_name'] = wallet_name.text } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index b23cd8b26..0d8f1ecb9 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -1,5 +1,5 @@ import QtQuick 2.6 -import QtQuick.Controls 2.3 +import QtQuick.Controls 2.15 import QtQuick.Layouts 1.0 import QtQuick.Controls.Material 2.0 @@ -10,6 +10,8 @@ ApplicationWindow { id: app visible: true + + // dimensions ignored on android width: 480 height: 800 @@ -55,7 +57,7 @@ ApplicationWindow Label { id: networkNameLabel text: Network.networkName - color: Material.accentColor //'orange' + color: Material.accentColor font.pointSize: 5 } } @@ -85,7 +87,7 @@ ApplicationWindow id: mainStackView anchors.fill: parent - initialItem: Qt.resolvedUrl('landing.qml') + initialItem: Qt.resolvedUrl('WalletMainView.qml') } Timer { @@ -124,6 +126,22 @@ ApplicationWindow } } + property alias serverConnectWizard: _serverConnectWizard + Component { + id: _serverConnectWizard + ServerConnectWizard { + parent: Overlay.overlay + x: 12 + y: 12 + width: parent.width - 24 + height: parent.height - 24 + + Overlay.modal: Rectangle { + color: "#aa000000" + } + } + } + property alias messageDialog: _messageDialog Component { id: _messageDialog @@ -144,8 +162,17 @@ ApplicationWindow } Component.onCompleted: { - Daemon.load_wallet() + //Daemon.load_wallet() splashTimer.start() + if (!Config.autoConnectDefined) { + var dialog = serverConnectWizard.createObject(app) + // without completed serverConnectWizard we can't start + dialog.rejected.connect(function() { + app.visible = false + Qt.callLater(Qt.quit) + }) + dialog.open() + } } onClosing: { diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py new file mode 100644 index 000000000..d65e22819 --- /dev/null +++ b/electrum/gui/qml/qeconfig.py @@ -0,0 +1,47 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger + +class QEConfig(QObject): + def __init__(self, config, parent=None): + super().__init__(parent) + self.config = config + + _logger = get_logger(__name__) + + autoConnectChanged = pyqtSignal() + serverStringChanged = pyqtSignal() + manualServerChanged = pyqtSignal() + + @pyqtProperty(bool, notify=autoConnectChanged) + def autoConnect(self): + return self.config.get('auto_connect') + + @autoConnect.setter + def autoConnect(self, auto_connect): + self.config.set_key('auto_connect', auto_connect, True) + self.autoConnectChanged.emit() + + # auto_connect is actually a tri-state, expose the undefined case + @pyqtProperty(bool, notify=autoConnectChanged) + def autoConnectDefined(self): + return self.config.get('auto_connect') is not None + + @pyqtProperty('QString', notify=serverStringChanged) + def serverString(self): + return self.config.get('server') + + @serverString.setter + def serverString(self, server): + self.config.set_key('server', server, True) + self.serverStringChanged.emit() + + @pyqtProperty(bool, notify=manualServerChanged) + def manualServer(self): + return self.config.get('oneserver') + + @manualServer.setter + def manualServer(self, oneserver): + self.config.set_key('oneserver', oneserver, True) + self.manualServerChanged.emit() + diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index a857fdf1a..11c696c1d 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -3,6 +3,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.util import register_callback from electrum.logging import get_logger from electrum import constants +from electrum.interface import ServerAddr class QENetwork(QObject): def __init__(self, network, parent=None): @@ -20,6 +21,7 @@ class QENetwork(QObject): blockchainUpdated = pyqtSignal() defaultServerChanged = pyqtSignal() proxySet = pyqtSignal() + proxyChanged = pyqtSignal() statusUpdated = pyqtSignal() dataChanged = pyqtSignal() # dummy to silence warnings @@ -64,6 +66,17 @@ class QENetwork(QObject): def server(self): return self._server + @server.setter + def server(self, server): + net_params = self.network.get_parameters() + try: + server = ServerAddr.from_str_with_inference(server) + if not server: raise Exception("failed to parse") + except Exception: + return + net_params = net_params._replace(server=server) + self.network.run_from_another_thread(self.network.set_parameters(net_params)) + @pyqtProperty('QString',notify=statusUpdated) def status(self): return self._status @@ -76,3 +89,16 @@ class QENetwork(QObject): def networkName(self): return constants.net.__name__.replace('Bitcoin','') + @pyqtProperty('QVariantMap', notify=proxyChanged) + def proxy(self): + net_params = self.network.get_parameters() + return net_params + + @proxy.setter + def proxy(self, proxy_settings): + net_params = self.network.get_parameters() + if not proxy_settings['enabled']: + proxy_settings = None + net_params = net_params._replace(proxy=proxy_settings) + self.network.run_from_another_thread(self.network.set_parameters(net_params)) + self.proxyChanged.emit() From 634a647fb12811a18d90a5da5721610a7c55a5d0 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sun, 6 Mar 2022 19:27:30 +0100 Subject: [PATCH 022/218] android: parameterize GUI framework --- contrib/android/Makefile | 1 - contrib/android/make_apk | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/contrib/android/Makefile b/contrib/android/Makefile index f9c11491f..1dcf7ecf4 100644 --- a/contrib/android/Makefile +++ b/contrib/android/Makefile @@ -28,7 +28,6 @@ theming: #$(PYTHON) -m kivy.atlas ../../electrum/gui/kivy/theming/atlas/light 1024 ../../electrum/gui/kivy/theming/light/*.png prepare: # running pre build setup - @cp buildozer_qml.spec ../../buildozer.spec # copy electrum to main.py @cp buildozer_$(ELEC_APK_GUI).spec ../../buildozer.spec @cp ../../run_electrum ../../main.py diff --git a/contrib/android/make_apk b/contrib/android/make_apk index d87265333..f8c11665a 100755 --- a/contrib/android/make_apk +++ b/contrib/android/make_apk @@ -8,6 +8,9 @@ PROJECT_ROOT="$CONTRIB"/.. PACKAGES="$PROJECT_ROOT"/packages/ LOCALE="$PROJECT_ROOT"/electrum/locale/ +# qml or kivy +export ELEC_APK_GUI=qml + . "$CONTRIB"/build_tools_util.sh From 2b7f22d27d18a3f61c265cf4796f96ffbef71c3a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 7 Feb 2022 17:22:08 +0100 Subject: [PATCH 023/218] create available wallet listmodel class --- electrum/gui/qml/qedaemon.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 24a168202..5e17a59a8 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -51,6 +51,27 @@ class QEWalletListModel(QAbstractListModel): self.wallets.append(item); self.endInsertRows(); +class QEAvailableWalletListModel(QEWalletListModel): + def __init__(self, daemon, parent=None): + QEWalletListModel.__init__(self, parent) + self.daemon = daemon + self.reload() + + def reload(self): + if len(self.wallets) > 0: + self.beginRemoveRows(QModelIndex(), 0, len(self.wallets) - 1) + self.wallets = [] + self.endRemoveRows() + + available = [] + wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path()) + with os.scandir(wallet_folder) as it: + for i in it: + if i.is_file(): + available.append(i.path) + for path in sorted(available): + wallet = self.daemon.get_wallet(path) + self.add_wallet(wallet_path = path, wallet = wallet) class QEDaemon(QObject): def __init__(self, daemon, parent=None): From 4b3f79f41c2673ca4b0b02d38dfbabdc52001844 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 8 Feb 2022 10:33:29 +0100 Subject: [PATCH 024/218] use QEAvailableWalletListModel for available wallets --- electrum/gui/qml/qedaemon.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 5e17a59a8..33eddf6b2 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -80,10 +80,14 @@ class QEDaemon(QObject): _logger = get_logger(__name__) _loaded_wallets = QEWalletListModel() + _available_wallets = None walletLoaded = pyqtSignal() walletRequiresPassword = pyqtSignal() + activeWalletsChanged = pyqtSignal() + availableWalletsChanged = pyqtSignal() + _current_wallet = None @pyqtSlot() @@ -102,30 +106,23 @@ class QEDaemon(QObject): self._logger.info('fail open wallet') self.walletRequiresPassword.emit() - @pyqtProperty(QEWallet,notify=walletLoaded) + @pyqtProperty(QEWallet, notify=walletLoaded) def currentWallet(self): return self._current_wallet - @pyqtProperty('QString',notify=walletLoaded) + @pyqtProperty('QString', notify=walletLoaded) def walletName(self): if self._current_wallet != None: return self._current_wallet.wallet.basename() return '' - @pyqtProperty(QEWalletListModel) + @pyqtProperty(QEWalletListModel, notify=activeWalletsChanged) def activeWallets(self): return self._loaded_wallets - @pyqtProperty(QEWalletListModel) + @pyqtProperty(QEAvailableWalletListModel, notify=availableWalletsChanged) def availableWallets(self): - available = [] - availableListModel = QEWalletListModel(self) - wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path()) - with os.scandir(wallet_folder) as it: - for i in it: - if i.is_file(): - available.append(i.path) - for path in sorted(available): - wallet = self.daemon.get_wallet(path) - availableListModel.add_wallet(wallet_path = path, wallet = wallet) - return availableListModel + if not self._available_wallets: + self._available_wallets = QEAvailableWalletListModel(self.daemon) + + return self._available_wallets From 0682f05d958032dd5a4452b7c28f118fbac5c3a5 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 8 Feb 2022 17:02:51 +0100 Subject: [PATCH 025/218] factor off the main QGuiApplication class into its own file --- electrum/gui/qml/__init__.py | 58 ++++-------------------------- electrum/gui/qml/qeapp.py | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 52 deletions(-) create mode 100644 electrum/gui/qml/qeapp.py diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 42a4ee40c..b8ed7f2d8 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -3,6 +3,7 @@ import signal import sys import traceback import threading +import re from typing import Optional, TYPE_CHECKING, List try: @@ -15,12 +16,9 @@ try: except Exception: sys.exit("Error: Could not import PyQt5.QtQml on Linux systems, you may try 'sudo apt-get install python3-pyqt5.qtquick'") -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QLocale, QTimer, qInstallMessageHandler +from PyQt5.QtCore import QLocale, QTimer from PyQt5.QtGui import QGuiApplication -from PyQt5.QtQml import qmlRegisterType, QQmlComponent, QQmlApplicationEngine -from PyQt5.QtQuick import QQuickView import PyQt5.QtCore as QtCore -import PyQt5.QtQml as QtQml from electrum.i18n import _, set_language, languages from electrum.plugin import run_hook @@ -36,51 +34,7 @@ if TYPE_CHECKING: from electrum.simple_config import SimpleConfig from electrum.plugin import Plugins -from .qeconfig import QEConfig -from .qedaemon import QEDaemon, QEWalletListModel -from .qenetwork import QENetwork -from .qewallet import QEWallet -from .qeqr import QEQR - -class ElectrumQmlApplication(QGuiApplication): - def __init__(self, args, config, daemon): - super().__init__(args) - - self.logger = get_logger(__name__ + '.engine') - - qmlRegisterType(QEWalletListModel, 'org.electrum', 1, 0, 'WalletListModel') - qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet') - - self.engine = QQmlApplicationEngine(parent=self) - self.engine.addImportPath('./qml') - - self.context = self.engine.rootContext() - self._singletons['config'] = QEConfig(config) - self._singletons['network'] = QENetwork(daemon.network) - self._singletons['daemon'] = QEDaemon(daemon) - self._singletons['qr'] = QEQR() - self.context.setContextProperty('Config', self._singletons['config']) - self.context.setContextProperty('Network', self._singletons['network']) - self.context.setContextProperty('Daemon', self._singletons['daemon']) - self.context.setContextProperty('QR', self._singletons['qr']) - - qInstallMessageHandler(self.message_handler) - - # get notified whether root QML document loads or not - self.engine.objectCreated.connect(self.objectCreated) - - _valid = True - _singletons = {} - - # slot is called after loading root QML. If object is None, it has failed. - @pyqtSlot('QObject*', 'QUrl') - def objectCreated(self, object, url): - if object is None: - self._valid = False - self.engine.objectCreated.disconnect(self.objectCreated) - - def message_handler(self, line, funct, file): - self.logger.warning(file) +from .qeapp import ElectrumQmlApplication class ElectrumGui(Logger): @@ -109,10 +63,10 @@ class ElectrumGui(Logger): os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" self.gui_thread = threading.current_thread() - self.config = config - self.daemon = daemon + #self.config = config + #self.daemon = daemon self.plugins = plugins - self.app = ElectrumQmlApplication(sys.argv, self.config, self.daemon) + self.app = ElectrumQmlApplication(sys.argv, config, daemon) # timer self.timer = QTimer(self.app) self.timer.setSingleShot(False) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py new file mode 100644 index 000000000..b8235e820 --- /dev/null +++ b/electrum/gui/qml/qeapp.py @@ -0,0 +1,68 @@ +import re + +from PyQt5.QtCore import pyqtSlot, QObject, QUrl, QLocale, qInstallMessageHandler +from PyQt5.QtGui import QGuiApplication +from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine #, QQmlComponent + +from electrum.logging import Logger, get_logger + +from .qeconfig import QEConfig +from .qedaemon import QEDaemon, QEWalletListModel +from .qenetwork import QENetwork +from .qewallet import QEWallet +from .qeqr import QEQR +from .qewalletdb import QEWalletDB + +class ElectrumQmlApplication(QGuiApplication): + + _config = None + _daemon = None + _singletons = {} + + def __init__(self, args, config, daemon): + super().__init__(args) + + self.logger = get_logger(__name__) + + ElectrumQmlApplication._config = config + ElectrumQmlApplication._daemon = daemon + + qmlRegisterType(QEWalletListModel, 'org.electrum', 1, 0, 'WalletListModel') + qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet') + qmlRegisterType(QEWalletDB, 'org.electrum', 1, 0, 'WalletDB') + + self.engine = QQmlApplicationEngine(parent=self) + self.engine.addImportPath('./qml') + + self.context = self.engine.rootContext() + self._singletons['config'] = QEConfig(config) + self._singletons['network'] = QENetwork(daemon.network) + self._singletons['daemon'] = QEDaemon(daemon) + self._singletons['qr'] = QEQR() + self.context.setContextProperty('Config', self._singletons['config']) + self.context.setContextProperty('Network', self._singletons['network']) + self.context.setContextProperty('Daemon', self._singletons['daemon']) + self.context.setContextProperty('QR', self._singletons['qr']) + + qInstallMessageHandler(self.message_handler) + + # get notified whether root QML document loads or not + self.engine.objectCreated.connect(self.objectCreated) + + + _valid = True + + # slot is called after loading root QML. If object is None, it has failed. + @pyqtSlot('QObject*', 'QUrl') + def objectCreated(self, object, url): + if object is None: + self._valid = False + self.engine.objectCreated.disconnect(self.objectCreated) + + def message_handler(self, line, funct, file): + # filter out common harmless messages + if re.search('file:///.*TypeError:\ Cannot\ read\ property.*null$', file): + return + self.logger.warning(file) + + From f9245164bb3c54f9191c5a62a3f8392b89bb0bd7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 15 Feb 2022 13:11:40 +0100 Subject: [PATCH 026/218] remove devtest buttons --- electrum/gui/qml/components/WalletMainView.qml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 302d586a1..3f7235a64 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -45,22 +45,6 @@ Item { width: parent.width y: 20 spacing: 20 - - Button { - onClicked: stack.push(Qt.resolvedUrl('Wallets.qml')) - text: 'Wallets' - Layout.alignment: Qt.AlignHCenter - } - - Button { - text: 'Create Wallet' - Layout.alignment: Qt.AlignHCenter - onClicked: { - var dialog = app.newWalletWizard.createObject(rootItem) - dialog.open() - } - } - } } From 54fe17b403a294f9e362d91097ebcc9f161f39d0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 16 Feb 2022 18:07:28 +0100 Subject: [PATCH 027/218] introduce QEWalletDb class to expose electrum wallet db to qml --- electrum/gui/qml/components/Wallets.qml | 23 ++- electrum/gui/qml/qewalletdb.py | 203 ++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 electrum/gui/qml/qewalletdb.py diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 6fa0195de..77cd17cb7 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -2,6 +2,8 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 +import org.electrum 1.0 + Pane { id: rootItem @@ -60,7 +62,7 @@ Pane { width: parent.width // Layout.fillHeight: true height: parent.height - clip:true + clip: true model: Daemon.availableWallets // header: sadly seems to be buggy @@ -78,15 +80,14 @@ Pane { } Label { - font.pointSize: 12 + font.pointSize: 11 text: model.name Layout.fillWidth: true } + Button { text: 'Load' - onClicked: { - Daemon.load_wallet(model.path, null) - } + onClicked: wallet_db.path = model.path } } @@ -102,4 +103,16 @@ Pane { } } } + + WalletDB { + id: wallet_db + onPathChanged: { + if (!ready) { + app.stack.push(Qt.resolvedUrl("OpenWallet.qml"), {"path": wallet_db.path}) + } else { + Daemon.load_wallet(wallet_db.path, null) + app.stack.pop() + } + } + } } diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py new file mode 100644 index 000000000..8ece5bb5f --- /dev/null +++ b/electrum/gui/qml/qewalletdb.py @@ -0,0 +1,203 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import Logger, get_logger +from electrum.storage import WalletStorage +from electrum.wallet_db import WalletDB +from electrum.util import InvalidPassword + +from .qedaemon import QEDaemon + +class QEWalletDB(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + from .qeapp import ElectrumQmlApplication + self.daemon = ElectrumQmlApplication._daemon + + self.reset() + + _logger = get_logger(__name__) + + fileNotFound = pyqtSignal() + pathChanged = pyqtSignal([bool], arguments=["ready"]) + needsPasswordChanged = pyqtSignal() + needsHWDeviceChanged = pyqtSignal() + passwordChanged = pyqtSignal() + invalidPasswordChanged = pyqtSignal() + requiresSplitChanged = pyqtSignal() + requiresUpgradeChanged = pyqtSignal() + upgradingChanged = pyqtSignal() + splitFinished = pyqtSignal() + readyChanged = pyqtSignal() + + def reset(self): + self._path = None + self._needsPassword = False + self._needsHWDevice = False + self._password = '' + self._requiresSplit = False + self._requiresUpgrade = False + self._upgrading = False + self._invalidPassword = False + + self._storage = None + self._db = None + + self._ready = False + + @pyqtProperty('QString', notify=pathChanged) + def path(self): + return self._path + + @path.setter + def path(self, wallet_path): + if wallet_path == self._path: + return + + self.reset() + self._logger.warning('path: ' + wallet_path) + self._path = wallet_path + + self.load_storage() + if self._storage: + self.load_db() + + self.pathChanged.emit(self._ready) + + @pyqtProperty(bool, notify=needsPasswordChanged) + def needsPassword(self): + return self._needsPassword + + @needsPassword.setter + def needsPassword(self, wallet_needs_password): + if wallet_needs_password == self._needsPassword: + return + + self._needsPassword = wallet_needs_password + self.needsPasswordChanged.emit() + + @pyqtProperty(bool, notify=needsHWDeviceChanged) + def needsHWDevice(self): + return self._needsHWDevice + + @needsHWDevice.setter + def needsHWDevice(self, wallet_needs_hw_device): + if wallet_needs_hw_device == self._needsHWDevice: + return + + self._needsHWDevice = wallet_needs_hw_device + self.needsHWDeviceChanged.emit() + + @pyqtProperty('QString', notify=passwordChanged) + def password(self): + return '' # no read access + + @password.setter + def password(self, wallet_password): + if wallet_password == self._password: + return + + self._password = wallet_password + self.passwordChanged.emit() + + self.load_storage() + + if self._storage: + self.needsPassword = False + self.load_db() + + @pyqtProperty(bool, notify=requiresSplitChanged) + def requiresSplit(self): + return self._requiresSplit + + @pyqtProperty(bool, notify=requiresUpgradeChanged) + def requiresUpgrade(self): + return self._requiresUpgrade + + @pyqtProperty(bool, notify=upgradingChanged) + def upgrading(self): + return self._upgrading + + @pyqtProperty(bool, notify=invalidPasswordChanged) + def invalidPassword(self): + return self._invalidPassword + + @pyqtProperty(bool, notify=readyChanged) + def ready(self): + return self._ready + + + @pyqtSlot() + def doSplit(self): + self._logger.warning('doSplit') + if not self._requiresSplit: + return + + self._db.split_accounts(self._path) + + self.splitFinished.emit() + + @pyqtSlot() + def doUpgrade(self): + self._logger.warning('doUpgrade') + if not self._requiresUpgrade: + return + + self._logger.warning('upgrading') + + self._upgrading = True + self.upgradingChanged.emit() + + self._db.upgrade() + self._db.write(self._storage) + + self._upgrading = False + self.upgradingChanged.emit() + + def load_storage(self): + self._storage = WalletStorage(self._path) + if not self._storage.file_exists(): + self._logger.warning('file does not exist') + self.fileNotFound.emit() + self._storage = None + return + + if self._storage.is_encrypted(): + self.needsPassword = True + + try: + self._storage.decrypt(self._password) + self._invalidPassword = False + except InvalidPassword as e: + self._invalidPassword = True + self.invalidPasswordChanged.emit() + + if not self._storage.is_past_initial_decryption(): + self._storage = None + + def load_db(self): + # needs storage accessible + self._db = WalletDB(self._storage.read(), manual_upgrades=True) + if self._db.requires_split(): + self._logger.warning('wallet requires split') + self._requiresSplit = True + self.requiresSplitChanged.emit() + return + if self._db.requires_upgrade(): + self._logger.warning('requires upgrade') + self._requiresUpgrade = True + self.requiresUpgradeChanged.emit() + return + if self._db.get_action(): + self._logger.warning('action pending. QML version doesn\'t support continuation of wizard') + return + + self._ready = True + self.readyChanged.emit() + + self.daemon.load_wallet(self._path, self._password) + + #wallet = Wallet(db, storage, config=self.config) + #wallet.start_network(self.network) + #self._wallets[path] = wallet + #return wallet From 63663b2b2d65f4d94eec4d719f67262bc993f13d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 21 Feb 2022 14:51:02 +0100 Subject: [PATCH 028/218] add simple message pane component --- electrum/gui/qml/components/MessagePane.qml | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 electrum/gui/qml/components/MessagePane.qml diff --git a/electrum/gui/qml/components/MessagePane.qml b/electrum/gui/qml/components/MessagePane.qml new file mode 100644 index 000000000..9be266327 --- /dev/null +++ b/electrum/gui/qml/components/MessagePane.qml @@ -0,0 +1,30 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +Rectangle { + id: item + + property bool warning + property bool error + property string text + + color: "transparent" + border.color: error ? "red" : warning ? "yellow" : Material.accentColor + border.width: 1 + height: text.height + 2* 16 + radius: 8 + + Text { + id: text + width: item.width - 2* 16 + x: 16 + y: 16 + + color: item.border.color + text: item.text + wrapMode: Text.Wrap + } + +} From ba58c6357e8e4b021eacc8041dad966c4cc3b4cc Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 24 Feb 2022 16:14:55 +0100 Subject: [PATCH 029/218] add initial dialog for opening wallets, initial coverage also for splitting and db upgrades --- electrum/gui/qml/components/OpenWallet.qml | 126 +++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 electrum/gui/qml/components/OpenWallet.qml diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml new file mode 100644 index 000000000..d072b54b1 --- /dev/null +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -0,0 +1,126 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +Pane { + id: openwalletdialog + + property string title: qsTr("Open Wallet") + + property string name + property string path + + property bool _unlockClicked: false + + GridLayout { + columns: 2 + width: parent.width + + Label { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: name + } + + MessagePane { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr("Wallet requires password to unlock") + visible: wallet_db.needsPassword + width: parent.width * 2/3 + warning: true + } + + MessagePane { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr("Invalid Password") + visible: wallet_db.invalidPassword && _unlockClicked + width: parent.width * 2/3 + error: true + } + + Label { + text: qsTr('Password') + visible: wallet_db.needsPassword + } + + TextField { + id: password + visible: wallet_db.needsPassword + echoMode: TextInput.Password + } + + Button { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + visible: wallet_db.needsPassword + text: qsTr("Unlock") + onClicked: { + _unlockClicked = true + wallet_db.password = password.text + } + } + + Label { + text: qsTr('Select HW device') + visible: wallet_db.needsHWDevice + } + + ComboBox { + id: hw_device + model: ['','Not implemented'] + visible: wallet_db.needsHWDevice + } + + Label { + text: qsTr('Wallet requires splitting') + visible: wallet_db.requiresSplit + } + + Button { + visible: wallet_db.requiresSplit + text: qsTr('Split wallet') + onClicked: wallet_db.doSplit() + } + + Label { + text: qsTr('Wallet requires upgrade') + visible: wallet_db.requiresUpgrade + } + + Button { + visible: wallet_db.requiresUpgrade + text: qsTr('Upgrade') + onClicked: wallet_db.doUpgrade() + } + + Rectangle { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + visible: wallet_db.upgrading + width: 100 + height: 100 + color: "red" + } + + } + + WalletDB { + id: wallet_db + path: openwalletdialog.path + onSplitFinished: { + // if wallet needed splitting, we close the pane and refresh the wallet list + Daemon.availableWallets.reload() + app.stack.pop() + } + onReadyChanged: { + if (ready) { + app.stack.pop(null) + } + } + } + +} From c999b3a2977929fef19e50e55c6e847531915cc7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 28 Feb 2022 19:40:57 +0100 Subject: [PATCH 030/218] add Bitcoin QObject for seed generation --- electrum/gui/qml/qeapp.py | 2 ++ electrum/gui/qml/qebitcoin.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 electrum/gui/qml/qebitcoin.py diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index b8235e820..6f9525345 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -12,6 +12,7 @@ from .qenetwork import QENetwork from .qewallet import QEWallet from .qeqr import QEQR from .qewalletdb import QEWalletDB +from .qebitcoin import QEBitcoin class ElectrumQmlApplication(QGuiApplication): @@ -30,6 +31,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEWalletListModel, 'org.electrum', 1, 0, 'WalletListModel') qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet') qmlRegisterType(QEWalletDB, 'org.electrum', 1, 0, 'WalletDB') + qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin') self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py new file mode 100644 index 000000000..06e1eb5e5 --- /dev/null +++ b/electrum/gui/qml/qebitcoin.py @@ -0,0 +1,27 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum import mnemonic + +class QEBitcoin(QObject): + def __init__(self, config, parent=None): + super().__init__(parent) + self.config = config + + _logger = get_logger(__name__) + + generatedSeedChanged = pyqtSignal() + generatedSeed = '' + + @pyqtProperty('QString', notify=generatedSeedChanged) + def generated_seed(self): + return self.generatedSeed + + @pyqtSlot() + @pyqtSlot(str) + @pyqtSlot(str,str) + def generate_seed(self, seed_type='standard', language='en'): + self._logger.debug('generating seed of type ' + str(seed_type)) + self.generatedSeed = mnemonic.Mnemonic(language).make_seed(seed_type=seed_type) + self._logger.debug('seed generated') + self.generatedSeedChanged.emit() From b1bd4d5acb08580bb9e7d45037e7400be7c4fb30 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 28 Feb 2022 19:41:44 +0100 Subject: [PATCH 031/218] add seed generation and verification for standard wallets --- electrum/gui/qml/components/Wizard.qml | 1 + .../gui/qml/components/WizardComponent.qml | 1 + .../gui/qml/components/WizardComponents.qml | 77 +++++++++++++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/electrum/gui/qml/components/Wizard.qml b/electrum/gui/qml/components/Wizard.qml index b166297f7..1c6d5c341 100644 --- a/electrum/gui/qml/components/Wizard.qml +++ b/electrum/gui/qml/components/Wizard.qml @@ -36,6 +36,7 @@ Dialog { pages.lastpage = page.last } ) Object.assign(page.wizard_data, wdata) // deep copy + page.ready = true // signal page it can access wizard_data pages.pagevalid = page.valid pages.lastpage = page.last diff --git a/electrum/gui/qml/components/WizardComponent.qml b/electrum/gui/qml/components/WizardComponent.qml index 34800b272..aba384ada 100644 --- a/electrum/gui/qml/components/WizardComponent.qml +++ b/electrum/gui/qml/components/WizardComponent.qml @@ -6,6 +6,7 @@ Item { property var wizard_data : ({}) property bool valid property bool last: false + property bool ready: false // onValidChanged: console.log('valid change in component itself') // onWizard_dataChanged: console.log('wizard data changed in ') } diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index 6e22726fd..dcd5b4afe 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -1,6 +1,9 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 Item { property Component walletname: Component { @@ -114,22 +117,45 @@ Item { onAccept: { wizard_data['seed'] = seedtext.text wizard_data['seed_extend'] = extendcb.checked + wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' } GridLayout { + width: parent.width columns: 1 - Label { text: qsTr('Generating seed') } + + Label { text: qsTr('Generated Seed') } TextArea { id: seedtext - text: 'test this is a fake seed as you might expect' readOnly: true Layout.fillWidth: true wrapMode: TextInput.WordWrap + background: Rectangle { + color: "transparent" + border.color: Material.accentColor + } + leftInset: -5 + rightInset: -5 } CheckBox { id: extendcb text: qsTr('Extend seed with custom words') } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + } + Component.onCompleted : { + bitcoin.generate_seed() + } + } + + Bitcoin { + id: bitcoin + onGeneratedSeedChanged: seedtext.text = generated_seed } } } @@ -145,45 +171,84 @@ Item { } GridLayout { + width: parent.width columns: 1 + Label { text: qsTr('Enter your seed') } TextArea { id: seedtext wrapMode: TextInput.WordWrap Layout.fillWidth: true + background: Rectangle { + color: "transparent" + border.color: Material.accentColor + } + leftInset: -5 + rightInset: -5 } CheckBox { id: extendcb enabled: true text: qsTr('Extend seed with custom words') } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + } CheckBox { id: bip39cb enabled: true text: qsTr('BIP39') } } + + Bitcoin { + id: bitcoin + } } } property Component confirmseed: Component { WizardComponent { - valid: confirm.text !== '' - Layout.fillWidth: true + valid: false + + function checkValid() { + var seedvalid = confirm.text == wizard_data['seed'] + var customwordsvalid = customwordstext.text == wizard_data['seed_extra_words'] + valid = seedvalid && (wizard_data['seed_extend'] ? customwordsvalid : true) + } GridLayout { - Layout.fillWidth: true + width: parent.width columns: 1 + Label { text: qsTr('Confirm your seed (re-enter)') } TextArea { id: confirm wrapMode: TextInput.WordWrap Layout.fillWidth: true onTextChanged: { - console.log("TODO: verify seed") + checkValid() + } + } + TextField { + id: customwordstext + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + onTextChanged: { + checkValid() } } } + + onReadyChanged: { + if (ready) + customwordstext.visible = wizard_data['seed_extend'] + } } } From 553ccdebd178cfdca943800900dec0bde561de68 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 7 Mar 2022 18:18:18 +0100 Subject: [PATCH 032/218] qml: eliminate animation glitching when removing a page from the wizard (back button) --- electrum/gui/qml/components/Wizard.qml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/Wizard.qml b/electrum/gui/qml/components/Wizard.qml index 1c6d5c341..a4a4e1b74 100644 --- a/electrum/gui/qml/components/Wizard.qml +++ b/electrum/gui/qml/components/Wizard.qml @@ -24,6 +24,11 @@ Dialog { // page.last -> pages.lastpage to propagate the state without the binding // going stale. function _loadNextComponent(comp, wdata={}) { + // remove any existing pages after current page + while (pages.contentChildren[pages.currentIndex+1]) { + pages.takeItem(pages.currentIndex+1).destroy() + } + var page = comp.createObject(pages, { 'visible': Qt.binding(function() { return pages.currentItem === this @@ -56,7 +61,6 @@ Dialog { _setWizardData(pages.contentChildren[currentIndex].wizard_data) pages.pagevalid = pages.contentChildren[currentIndex].valid pages.lastpage = pages.contentChildren[currentIndex].last - pages.contentChildren[currentIndex+1].destroy() } function next() { From 064ac5505919cc62dbb3340ab871e9b0892c07f5 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 8 Mar 2022 14:56:40 +0100 Subject: [PATCH 033/218] determine android_gui at runtime --- run_electrum | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/run_electrum b/run_electrum index 5b403977b..23822cf09 100755 --- a/run_electrum +++ b/run_electrum @@ -322,12 +322,14 @@ def main(): # config is an object passed to the various constructors (wallet, interface, gui) if is_android: + import importlib + android_gui = 'kivy' if importlib.find_loader('kivy') else 'qml' from jnius import autoclass build_config = autoclass("org.electrum.electrum.BuildConfig") config_options = { 'verbosity': '*', #if build_config.DEBUG else '', 'cmd': 'gui', - 'gui': 'qml', + 'gui': android_gui, 'single_password':True, } else: From 08154da3b6aed976a9774187e21f7f6d81c34a71 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 8 Mar 2022 14:58:01 +0100 Subject: [PATCH 034/218] add command line parameters to android/build.sh, and use separate .buildozer dirs for kivy and qt5 --- contrib/android/make_apk | 3 --- 1 file changed, 3 deletions(-) diff --git a/contrib/android/make_apk b/contrib/android/make_apk index f8c11665a..d87265333 100755 --- a/contrib/android/make_apk +++ b/contrib/android/make_apk @@ -8,9 +8,6 @@ PROJECT_ROOT="$CONTRIB"/.. PACKAGES="$PROJECT_ROOT"/packages/ LOCALE="$PROJECT_ROOT"/electrum/locale/ -# qml or kivy -export ELEC_APK_GUI=qml - . "$CONTRIB"/build_tools_util.sh From e243aa22e73efcb6bd475633183b8a2466d5c120 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 8 Mar 2022 11:57:30 +0100 Subject: [PATCH 035/218] remove cruft --- electrum/gui/qml/components/ServerConnectWizard.qml | 1 - electrum/gui/qml/components/WizardComponent.qml | 2 -- 2 files changed, 3 deletions(-) diff --git a/electrum/gui/qml/components/ServerConnectWizard.qml b/electrum/gui/qml/components/ServerConnectWizard.qml index 5562b56e8..cea2ecd8b 100644 --- a/electrum/gui/qml/components/ServerConnectWizard.qml +++ b/electrum/gui/qml/components/ServerConnectWizard.qml @@ -80,7 +80,6 @@ Wizard { property Component proxyconfig: Component { WizardComponent { valid: true - last: false onAccept: { var p = {} diff --git a/electrum/gui/qml/components/WizardComponent.qml b/electrum/gui/qml/components/WizardComponent.qml index aba384ada..798b7ad8d 100644 --- a/electrum/gui/qml/components/WizardComponent.qml +++ b/electrum/gui/qml/components/WizardComponent.qml @@ -7,6 +7,4 @@ Item { property bool valid property bool last: false property bool ready: false -// onValidChanged: console.log('valid change in component itself') -// onWizard_dataChanged: console.log('wizard data changed in ') } From 145e7e3440c5afdc0cb1e5c0f2b35b7e7ab1f06f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 8 Mar 2022 12:04:56 +0100 Subject: [PATCH 036/218] add seed warning texts --- .../gui/qml/components/WizardComponents.qml | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index dcd5b4afe..e367dcc50 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -116,6 +116,7 @@ Item { onAccept: { wizard_data['seed'] = seedtext.text + wizard_data['seed_type'] = 'segwit' wizard_data['seed_extend'] = extendcb.checked wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' } @@ -124,7 +125,15 @@ Item { width: parent.width columns: 1 - Label { text: qsTr('Generated Seed') } + TextArea { + id: warningtext + readOnly: true + Layout.fillWidth: true + wrapMode: TextInput.WordWrap + textFormat: TextEdit.RichText + background: Rectangle { color: "transparent" } + } + Label { text: qsTr('Your wallet generation seed is:') } TextArea { id: seedtext readOnly: true @@ -155,7 +164,23 @@ Item { Bitcoin { id: bitcoin - onGeneratedSeedChanged: seedtext.text = generated_seed + onGeneratedSeedChanged: { + seedtext.text = generated_seed + + var t = [ + "

", + qsTr("Please save these %1 words on paper (order is important). ").arg(generated_seed.split(" ").length), + qsTr("This seed will allow you to recover your wallet in case of computer failure."), + "

", + "" + qsTr("WARNING") + ":", + "
    ", + "
  • " + qsTr("Never disclose your seed.") + "
  • ", + "
  • " + qsTr("Never type it on a website.") + "
  • ", + "
  • " + qsTr("Do not store it electronically.") + "
  • ", + "
" + ] + warningtext.text = t.join("") + } } } } @@ -167,6 +192,7 @@ Item { onAccept: { wizard_data['seed'] = seedtext.text wizard_data['seed_extend'] = extendcb.checked + wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' wizard_data['seed_bip39'] = bip39cb.checked } @@ -225,6 +251,15 @@ Item { width: parent.width columns: 1 + TextArea { + readOnly: true + Layout.fillWidth: true + wrapMode: TextInput.WordWrap + text: qsTr('Your seed is important!') + ' ' + + qsTr('If you lose your seed, your money will be permanently lost.') + ' ' + + qsTr('To make sure that you have properly saved your seed, please retype it here.') + background: Rectangle { color: "transparent" } + } Label { text: qsTr('Confirm your seed (re-enter)') } TextArea { id: confirm From 4cae116ad877c1aa9711a4f8ed453d25d8b43920 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 8 Mar 2022 12:07:50 +0100 Subject: [PATCH 037/218] create wallet at end of new wallet wizard --- .../gui/qml/components/NewWalletWizard.qml | 13 ++++++ electrum/gui/qml/components/Wallets.qml | 3 ++ electrum/gui/qml/qebitcoin.py | 20 ++++++++- electrum/gui/qml/qedaemon.py | 1 + electrum/gui/qml/qewalletdb.py | 43 ++++++++++++++++++- 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index 781aca127..8a0c13c21 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -2,11 +2,15 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 +import org.electrum 1.0 + Wizard { id: walletwizard title: qsTr('New Wallet') + signal walletCreated + enter: null // disable transition // State transition functions. These functions are called when the 'Next' @@ -77,5 +81,14 @@ Wizard { start.next.connect(function() {walletnameDone()}) } + onAccepted: { + console.log('Finished new wallet wizard') + walletdb.create_storage(wizard_data) + } + + WalletDB { + id: walletdb + onCreateSuccess: walletwizard.walletCreated() + } } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 77cd17cb7..94e2b2292 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -100,6 +100,9 @@ Pane { onClicked: { var dialog = app.newWalletWizard.createObject(rootItem) dialog.open() + dialog.walletCreated.connect(function() { + Daemon.availableWallets.reload() + }) } } } diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 06e1eb5e5..13a0b0513 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -13,6 +13,9 @@ class QEBitcoin(QObject): generatedSeedChanged = pyqtSignal() generatedSeed = '' + seedValidChanged = pyqtSignal() + seedValid = False + @pyqtProperty('QString', notify=generatedSeedChanged) def generated_seed(self): return self.generatedSeed @@ -20,8 +23,23 @@ class QEBitcoin(QObject): @pyqtSlot() @pyqtSlot(str) @pyqtSlot(str,str) - def generate_seed(self, seed_type='standard', language='en'): + def generate_seed(self, seed_type='segwit', language='en'): self._logger.debug('generating seed of type ' + str(seed_type)) self.generatedSeed = mnemonic.Mnemonic(language).make_seed(seed_type=seed_type) self._logger.debug('seed generated') self.generatedSeedChanged.emit() + + @pyqtProperty(bool, notify=seedValidChanged) + def seed_valid(self): + return self.seedValid + + @pyqtSlot(str) + @pyqtSlot(str,str) + @pyqtSlot(str,str,str) + @pyqtSlot(str,str,str,str) + def verify_seed(self, seed, bip39=False, seed_type='segwit', language='en'): + self._logger.debug('verify seed of type ' + str(seed_type)) + #TODO + #self._logger.debug('seed verified') + #self.seedValidChanged.emit() + diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 33eddf6b2..91130f42e 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -57,6 +57,7 @@ class QEAvailableWalletListModel(QEWalletListModel): self.daemon = daemon self.reload() + @pyqtSlot() def reload(self): if len(self.wallets) > 0: self.beginRemoveRows(QModelIndex(), 0, len(self.wallets) - 1) diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index 8ece5bb5f..00f7fa1a9 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -1,9 +1,12 @@ +import os + from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import Logger, get_logger -from electrum.storage import WalletStorage +from electrum.storage import WalletStorage, StorageEncryptionVersion from electrum.wallet_db import WalletDB from electrum.util import InvalidPassword +from electrum import keystore from .qedaemon import QEDaemon @@ -29,6 +32,8 @@ class QEWalletDB(QObject): upgradingChanged = pyqtSignal() splitFinished = pyqtSignal() readyChanged = pyqtSignal() + createError = pyqtSignal([str], arguments=["error"]) + createSuccess = pyqtSignal() def reset(self): self._path = None @@ -201,3 +206,39 @@ class QEWalletDB(QObject): #wallet.start_network(self.network) #self._wallets[path] = wallet #return wallet + + @pyqtSlot('QJSValue') + def create_storage(self, js_data): + self._logger.info('Creating wallet from wizard data') + data = js_data.toVariant() + self._logger.debug(str(data)) + + try: + path = os.path.join(os.path.dirname(self.daemon.config.get_wallet_path()), data['wallet_name']) + if os.path.exists(path): + raise Exception('file already exists at path') + storage = WalletStorage(path) + + k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') + + if data['encrypt']: + storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) + + db = WalletDB('', manual_upgrades=False) + db.set_keystore_encryption(bool(data['password']) and data['encrypt']) + + db.put('wallet_type', data['wallet_type']) + db.put('seed_type', data['seed_type']) + db.put('keystore', k.dump()) + if k.can_have_deterministic_lightning_xprv(): + db.put('lightning_xprv', k.get_lightning_xprv(None)) + + db.load_plugins() + db.write(storage) + + self.createSuccess.emit() + except Exception as e: + self._logger.error(str(e)) + self.createError.emit(str(e)) + + From 07452a6a7ad0dabf07606c810d534ed07ee4b079 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 8 Mar 2022 15:24:02 +0100 Subject: [PATCH 038/218] seed generation can take some time, do it in a background thread and show a busy indicator while we wait --- .../gui/qml/components/WizardComponents.qml | 41 ++++++++++++------- electrum/gui/qml/qebitcoin.py | 13 ++++-- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index e367dcc50..62da140b3 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -112,7 +112,7 @@ Item { property Component createseed: Component { WizardComponent { - valid: true + valid: seedtext.text != '' onAccept: { wizard_data['seed'] = seedtext.text @@ -121,6 +121,22 @@ Item { wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' } + function setWarningText(numwords) { + var t = [ + "

", + qsTr("Please save these %1 words on paper (order is important). ").arg(numwords), + qsTr("This seed will allow you to recover your wallet in case of computer failure."), + "

", + "" + qsTr("WARNING") + ":", + "
    ", + "
  • " + qsTr("Never disclose your seed.") + "
  • ", + "
  • " + qsTr("Never type it on a website.") + "
  • ", + "
  • " + qsTr("Do not store it electronically.") + "
  • ", + "
" + ] + warningtext.text = t.join("") + } + GridLayout { width: parent.width columns: 1 @@ -145,6 +161,13 @@ Item { } leftInset: -5 rightInset: -5 + + BusyIndicator { + anchors.centerIn: parent + height: parent.height *2/3 + visible: seedtext.text == '' + } + } CheckBox { id: extendcb @@ -158,6 +181,7 @@ Item { echoMode: TextInput.Password } Component.onCompleted : { + setWarningText(12) bitcoin.generate_seed() } } @@ -166,20 +190,7 @@ Item { id: bitcoin onGeneratedSeedChanged: { seedtext.text = generated_seed - - var t = [ - "

", - qsTr("Please save these %1 words on paper (order is important). ").arg(generated_seed.split(" ").length), - qsTr("This seed will allow you to recover your wallet in case of computer failure."), - "

", - "" + qsTr("WARNING") + ":", - "
    ", - "
  • " + qsTr("Never disclose your seed.") + "
  • ", - "
  • " + qsTr("Never type it on a website.") + "
  • ", - "
  • " + qsTr("Do not store it electronically.") + "
  • ", - "
" - ] - warningtext.text = t.join("") + setWarningText(generated_seed.split(" ").length) } } } diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 13a0b0513..d4f602235 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -1,3 +1,5 @@ +import asyncio + from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger @@ -25,9 +27,14 @@ class QEBitcoin(QObject): @pyqtSlot(str,str) def generate_seed(self, seed_type='segwit', language='en'): self._logger.debug('generating seed of type ' + str(seed_type)) - self.generatedSeed = mnemonic.Mnemonic(language).make_seed(seed_type=seed_type) - self._logger.debug('seed generated') - self.generatedSeedChanged.emit() + + async def co_gen_seed(seed_type, language): + self.generatedSeed = mnemonic.Mnemonic(language).make_seed(seed_type=seed_type) + self._logger.debug('seed generated') + self.generatedSeedChanged.emit() + + loop = asyncio.get_event_loop() + asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), loop) @pyqtProperty(bool, notify=seedValidChanged) def seed_valid(self): From 5d5204db1ed5b4f8f37c33a7fc71a182db0d882f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 9 Mar 2022 13:36:34 +0100 Subject: [PATCH 039/218] wizard styling, infotext component, add some boilerplate for existing seed --- electrum/gui/qml/components/InfoTextArea.qml | 57 ++++++++++++++ electrum/gui/qml/components/Wizard.qml | 34 +++++++++ .../gui/qml/components/WizardComponents.qml | 75 ++++++++++++++----- 3 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 electrum/gui/qml/components/InfoTextArea.qml diff --git a/electrum/gui/qml/components/InfoTextArea.qml b/electrum/gui/qml/components/InfoTextArea.qml new file mode 100644 index 000000000..5f9d2dbec --- /dev/null +++ b/electrum/gui/qml/components/InfoTextArea.qml @@ -0,0 +1,57 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +GridLayout { + property alias text: infotext.text + + enum IconStyle { + None, + Info, + Warn, + Error + } + + property int iconStyle: InfoTextArea.IconStyle.Info + + columns: 1 + rowSpacing: 0 + + Rectangle { + height: 2 + Layout.fillWidth: true + color: Qt.rgba(1,1,1,0.25) + } + + TextArea { + id: infotext + Layout.fillWidth: true + readOnly: true + rightPadding: 16 + leftPadding: 64 + wrapMode: TextInput.WordWrap + textFormat: TextEdit.RichText + background: Rectangle { + color: Qt.rgba(1,1,1,0.05) // whiten 5% + } + + Image { + source: iconStyle == InfoTextArea.IconStyle.Info ? "../../icons/info.png" : InfoTextArea.IconStyle.Warn ? "../../icons/warning.png" : InfoTextArea.IconStyle.Error ? "../../icons/expired.png" : "" + anchors.left: parent.left + anchors.top: parent.top + anchors.leftMargin: 16 + anchors.topMargin: 16 + height: 32 + width: 32 + fillMode: Image.PreserveAspectCrop + } + + } + + Rectangle { + height: 2 + Layout.fillWidth: true + color: Qt.rgba(0,0,0,0.25) + } +} diff --git a/electrum/gui/qml/components/Wizard.qml b/electrum/gui/qml/components/Wizard.qml index a4a4e1b74..0f2e7872e 100644 --- a/electrum/gui/qml/components/Wizard.qml +++ b/electrum/gui/qml/components/Wizard.qml @@ -129,4 +129,38 @@ Dialog { } } + header: GridLayout { + columns: 2 + rowSpacing: 0 + + Image { + source: "../../icons/electrum.png" + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + Layout.leftMargin: 12 + Layout.topMargin: 12 + Layout.bottomMargin: 12 + } + + Label { + text: title + elide: Label.ElideRight + Layout.fillWidth: true + topPadding: 24 + bottomPadding: 24 + font.bold: true + font.pixelSize: 16 + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + height: 1 + color: Qt.rgba(0,0,0,0.5) + } + } + + } diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index 62da140b3..7f87f6693 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -141,13 +141,10 @@ Item { width: parent.width columns: 1 - TextArea { + InfoTextArea { id: warningtext - readOnly: true Layout.fillWidth: true - wrapMode: TextInput.WordWrap - textFormat: TextEdit.RichText - background: Rectangle { color: "transparent" } + iconStyle: InfoTextArea.IconStyle.Warn } Label { text: qsTr('Your wallet generation seed is:') } TextArea { @@ -198,7 +195,7 @@ Item { property Component haveseed: Component { WizardComponent { - valid: true + valid: false onAccept: { wizard_data['seed'] = seedtext.text @@ -207,44 +204,89 @@ Item { wizard_data['seed_bip39'] = bip39cb.checked } + function checkValid() { + } + + function setSeedTypeHelpText() { + var t = { + 'Electrum': [ + qsTr('Electrum seeds are the default seed type.'), + qsTr('If you are restoring from a seed previously created by Electrum, choose this option') + ].join(' '), + 'BIP39': [ + qsTr('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), + '

', + qsTr('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'), + qsTr('BIP39 seeds do not include a version number, which compromises compatibility with future software.'), + '

', + qsTr('We do not guarantee that BIP39 imports will always be supported in Electrum.') + ].join(' '), + 'SLIP39': [ + qsTr('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), + '

', + qsTr('However, we do not generate SLIP39 seeds.') + ].join(' ') + } + infotext.text = t[seed_type.currentText] + } + GridLayout { width: parent.width - columns: 1 + columns: 2 - Label { text: qsTr('Enter your seed') } + Label { + text: qsTr('Seed Type') + } + ComboBox { + id: seed_type + model: ['Electrum', 'BIP39', 'SLIP39'] + onActivated: setSeedTypeHelpText() + } + InfoTextArea { + id: infotext + Layout.fillWidth: true + Layout.columnSpan: 2 + } + Label { + text: qsTr('Enter your seed') + Layout.columnSpan: 2 + } TextArea { id: seedtext wrapMode: TextInput.WordWrap Layout.fillWidth: true + Layout.columnSpan: 2 background: Rectangle { color: "transparent" border.color: Material.accentColor } leftInset: -5 rightInset: -5 + onTextChanged: { + checkValid() + } } CheckBox { id: extendcb - enabled: true + Layout.columnSpan: 2 text: qsTr('Extend seed with custom words') } TextField { id: customwordstext visible: extendcb.checked Layout.fillWidth: true + Layout.columnSpan: 2 placeholderText: qsTr('Enter your custom word(s)') echoMode: TextInput.Password } - CheckBox { - id: bip39cb - enabled: true - text: qsTr('BIP39') - } } Bitcoin { id: bitcoin } + Component.onCompleted: { + setSeedTypeHelpText() + } } } @@ -262,14 +304,11 @@ Item { width: parent.width columns: 1 - TextArea { - readOnly: true + InfoTextArea { Layout.fillWidth: true - wrapMode: TextInput.WordWrap text: qsTr('Your seed is important!') + ' ' + qsTr('If you lose your seed, your money will be permanently lost.') + ' ' + qsTr('To make sure that you have properly saved your seed, please retype it here.') - background: Rectangle { color: "transparent" } } Label { text: qsTr('Confirm your seed (re-enter)') } TextArea { From 48d47b008e54085390473778ff61e41dc5d30d31 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 9 Mar 2022 14:10:14 +0100 Subject: [PATCH 040/218] move seed text component to its own type --- electrum/gui/qml/components/SeedTextArea.qml | 19 +++++++++++++++ .../gui/qml/components/WizardComponents.qml | 24 ++++--------------- 2 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 electrum/gui/qml/components/SeedTextArea.qml diff --git a/electrum/gui/qml/components/SeedTextArea.qml b/electrum/gui/qml/components/SeedTextArea.qml new file mode 100644 index 000000000..a6af6b700 --- /dev/null +++ b/electrum/gui/qml/components/SeedTextArea.qml @@ -0,0 +1,19 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +TextArea { + id: seedtext + Layout.fillWidth: true + Layout.minimumHeight: 80 + rightPadding: 16 + leftPadding: 16 + wrapMode: TextInput.WordWrap + font.bold: true + font.pixelSize: 18 + background: Rectangle { + color: "transparent" + border.color: Material.accentColor + } +} diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index 7f87f6693..ec674f1fe 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -147,24 +147,16 @@ Item { iconStyle: InfoTextArea.IconStyle.Warn } Label { text: qsTr('Your wallet generation seed is:') } - TextArea { + SeedTextArea { id: seedtext readOnly: true Layout.fillWidth: true - wrapMode: TextInput.WordWrap - background: Rectangle { - color: "transparent" - border.color: Material.accentColor - } - leftInset: -5 - rightInset: -5 BusyIndicator { anchors.centerIn: parent - height: parent.height *2/3 + height: parent.height * 2/3 visible: seedtext.text == '' } - } CheckBox { id: extendcb @@ -251,17 +243,10 @@ Item { text: qsTr('Enter your seed') Layout.columnSpan: 2 } - TextArea { + SeedTextArea { id: seedtext - wrapMode: TextInput.WordWrap Layout.fillWidth: true Layout.columnSpan: 2 - background: Rectangle { - color: "transparent" - border.color: Material.accentColor - } - leftInset: -5 - rightInset: -5 onTextChanged: { checkValid() } @@ -311,9 +296,8 @@ Item { qsTr('To make sure that you have properly saved your seed, please retype it here.') } Label { text: qsTr('Confirm your seed (re-enter)') } - TextArea { + SeedTextArea { id: confirm - wrapMode: TextInput.WordWrap Layout.fillWidth: true onTextChanged: { checkValid() From d49b168389b72259ddfdf94780c7f309b7ff6833 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 9 Mar 2022 15:32:37 +0100 Subject: [PATCH 041/218] wrap potentially large pages in a flickable, for small form factors --- .../gui/qml/components/WizardComponents.qml | 218 ++++++++++-------- 1 file changed, 121 insertions(+), 97 deletions(-) diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index ec674f1fe..c8dd3df93 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -137,41 +137,49 @@ Item { warningtext.text = t.join("") } - GridLayout { - width: parent.width - columns: 1 - - InfoTextArea { - id: warningtext - Layout.fillWidth: true - iconStyle: InfoTextArea.IconStyle.Warn - } - Label { text: qsTr('Your wallet generation seed is:') } - SeedTextArea { - id: seedtext - readOnly: true - Layout.fillWidth: true - - BusyIndicator { - anchors.centerIn: parent - height: parent.height * 2/3 - visible: seedtext.text == '' + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + InfoTextArea { + id: warningtext + Layout.fillWidth: true + iconStyle: InfoTextArea.IconStyle.Warn + } + Label { text: qsTr('Your wallet generation seed is:') } + SeedTextArea { + id: seedtext + readOnly: true + Layout.fillWidth: true + + BusyIndicator { + anchors.centerIn: parent + height: parent.height * 2/3 + visible: seedtext.text == '' + } + } + CheckBox { + id: extendcb + text: qsTr('Extend seed with custom words') + } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + } + Component.onCompleted : { + setWarningText(12) + bitcoin.generate_seed() } - } - CheckBox { - id: extendcb - text: qsTr('Extend seed with custom words') - } - TextField { - id: customwordstext - visible: extendcb.checked - Layout.fillWidth: true - placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password - } - Component.onCompleted : { - setWarningText(12) - bitcoin.generate_seed() } } @@ -222,47 +230,55 @@ Item { infotext.text = t[seed_type.currentText] } - GridLayout { - width: parent.width - columns: 2 + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight - Label { - text: qsTr('Seed Type') - } - ComboBox { - id: seed_type - model: ['Electrum', 'BIP39', 'SLIP39'] - onActivated: setSeedTypeHelpText() - } - InfoTextArea { - id: infotext - Layout.fillWidth: true - Layout.columnSpan: 2 - } - Label { - text: qsTr('Enter your seed') - Layout.columnSpan: 2 - } - SeedTextArea { - id: seedtext - Layout.fillWidth: true - Layout.columnSpan: 2 - onTextChanged: { - checkValid() + GridLayout { + id: mainLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Seed Type') + } + ComboBox { + id: seed_type + model: ['Electrum', 'BIP39', 'SLIP39'] + onActivated: setSeedTypeHelpText() + } + InfoTextArea { + id: infotext + Layout.fillWidth: true + Layout.columnSpan: 2 + } + Label { + text: qsTr('Enter your seed') + Layout.columnSpan: 2 + } + SeedTextArea { + id: seedtext + Layout.fillWidth: true + Layout.columnSpan: 2 + onTextChanged: { + checkValid() + } + } + CheckBox { + id: extendcb + Layout.columnSpan: 2 + text: qsTr('Extend seed with custom words') + } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + Layout.columnSpan: 2 + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password } - } - CheckBox { - id: extendcb - Layout.columnSpan: 2 - text: qsTr('Extend seed with custom words') - } - TextField { - id: customwordstext - visible: extendcb.checked - Layout.fillWidth: true - Layout.columnSpan: 2 - placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password } } @@ -285,31 +301,39 @@ Item { valid = seedvalid && (wizard_data['seed_extend'] ? customwordsvalid : true) } - GridLayout { - width: parent.width - columns: 1 - - InfoTextArea { - Layout.fillWidth: true - text: qsTr('Your seed is important!') + ' ' + - qsTr('If you lose your seed, your money will be permanently lost.') + ' ' + - qsTr('To make sure that you have properly saved your seed, please retype it here.') - } - Label { text: qsTr('Confirm your seed (re-enter)') } - SeedTextArea { - id: confirm - Layout.fillWidth: true - onTextChanged: { - checkValid() + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + InfoTextArea { + Layout.fillWidth: true + text: qsTr('Your seed is important!') + ' ' + + qsTr('If you lose your seed, your money will be permanently lost.') + ' ' + + qsTr('To make sure that you have properly saved your seed, please retype it here.') } - } - TextField { - id: customwordstext - Layout.fillWidth: true - placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password - onTextChanged: { - checkValid() + Label { text: qsTr('Confirm your seed (re-enter)') } + SeedTextArea { + id: confirm + Layout.fillWidth: true + onTextChanged: { + checkValid() + } + } + TextField { + id: customwordstext + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + onTextChanged: { + checkValid() + } } } } From c3bc42f43457114f2fdf441c507afd0d31a94a47 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 9 Mar 2022 19:20:36 +0100 Subject: [PATCH 042/218] add clipping for flickable --- electrum/gui/qml/components/Wizard.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/electrum/gui/qml/components/Wizard.qml b/electrum/gui/qml/components/Wizard.qml index 0f2e7872e..c7c4a23a2 100644 --- a/electrum/gui/qml/components/Wizard.qml +++ b/electrum/gui/qml/components/Wizard.qml @@ -50,12 +50,16 @@ Dialog { ColumnLayout { anchors.fill: parent + spacing: 0 SwipeView { id: pages Layout.fillWidth: true + Layout.fillHeight: true interactive: false + clip:true + function prev() { currentIndex = currentIndex - 1 _setWizardData(pages.contentChildren[currentIndex].wizard_data) From 539197e8f3b92ca9aafd45812e9a3d04bdf3fb9d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 10 Mar 2022 12:23:07 +0100 Subject: [PATCH 043/218] fix up styling ServerConnectWizard --- .../qml/components/ServerConnectWizard.qml | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/electrum/gui/qml/components/ServerConnectWizard.qml b/electrum/gui/qml/components/ServerConnectWizard.qml index cea2ecd8b..d29b484df 100644 --- a/electrum/gui/qml/components/ServerConnectWizard.qml +++ b/electrum/gui/qml/components/ServerConnectWizard.qml @@ -1,7 +1,6 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 +import QtQuick.Controls 2.3 Wizard { id: serverconnectwizard @@ -47,13 +46,11 @@ Wizard { } ColumnLayout { - anchors.fill: parent + width: parent.width - Text { + InfoTextArea { text: qsTr('Electrum communicates with remote servers to get information about your transactions and addresses. The servers all fulfill the same purpose only differing in hardware. In most cases you simply want to let Electrum pick one at random. However if you prefer feel free to select a server manually.') - wrapMode: Text.Wrap Layout.fillWidth: true - color: Material.primaryTextColor } ButtonGroup { @@ -98,13 +95,10 @@ Wizard { } ColumnLayout { - anchors.fill: parent + width: parent.width - Text { + Label { text: qsTr('Proxy settings') - wrapMode: Text.Wrap - Layout.fillWidth: true - color: Material.primaryTextColor } CheckBox { @@ -185,13 +179,10 @@ Wizard { } ColumnLayout { - anchors.fill: parent + width: parent.width - Text { + Label { text: qsTr('Server settings') - wrapMode: Text.Wrap - Layout.fillWidth: true - color: Material.primaryTextColor } CheckBox { From 670882c3c0583109ea97a8643c86d9e37b9b49bd Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 10 Mar 2022 12:25:18 +0100 Subject: [PATCH 044/218] improve wallet open flow remove load_wallet from walletDB, route all wallet loading through QEDaemon. QEDaemon emits walletLoaded and walletRequiresPassword signals. main.qml opens OpenWallet view when extra user interaction is needed --- electrum/gui/qml/components/OpenWallet.qml | 1 + .../gui/qml/components/WalletMainView.qml | 7 +++ electrum/gui/qml/components/Wallets.qml | 34 ++++++------ electrum/gui/qml/components/main.qml | 53 +++++++++++++------ electrum/gui/qml/qedaemon.py | 16 ++++-- electrum/gui/qml/qewalletdb.py | 7 --- 6 files changed, 77 insertions(+), 41 deletions(-) diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml index d072b54b1..47f2ade94 100644 --- a/electrum/gui/qml/components/OpenWallet.qml +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -118,6 +118,7 @@ Pane { } onReadyChanged: { if (ready) { + Daemon.load_wallet(Daemon.path, password.text) app.stack.pop(null) } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 3f7235a64..a5388a183 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -67,5 +67,12 @@ Item { } + Connections { + target: Daemon + function onWalletLoaded() { + tabbar.setCurrentIndex(1) + } + } + } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 94e2b2292..9877754e4 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -70,13 +70,17 @@ Pane { delegate: AbstractButton { width: ListView.view.width height: 50 - onClicked: console.log('delegate clicked') + onClicked: { + wallet_db.path = model.path + } + RowLayout { - x: 20 - spacing: 20 + x: 10 + spacing: 10 + width: parent.width - 20 Image { - source: "../../../gui/kivy/theming/light/wallet.png" + source: "../../kivy/theming/light/wallet.png" } Label { @@ -86,11 +90,12 @@ Pane { } Button { - text: 'Load' - onClicked: wallet_db.path = model.path + text: 'Open' + onClicked: { + Daemon.load_wallet(model.path) + } } } - } }}} @@ -107,15 +112,14 @@ Pane { } } + Connections { + target: Daemon + function onWalletLoaded() { + app.stack.pop() + } + } + WalletDB { id: wallet_db - onPathChanged: { - if (!ready) { - app.stack.push(Qt.resolvedUrl("OpenWallet.qml"), {"path": wallet_db.path}) - } else { - Daemon.load_wallet(wallet_db.path, null) - app.stack.pop() - } - } } } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 0d8f1ecb9..03852f8b5 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -1,6 +1,6 @@ import QtQuick 2.6 -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 import QtQuick.Controls.Material 2.0 import QtQml 2.6 @@ -36,7 +36,7 @@ ApplicationWindow MouseArea { anchors.fill: parent onClicked: { - var dialog = app.messageDialog.createObject(app, {'message': + var dialog = app.messageDialog.createObject(app, {'text': 'Electrum is currently on ' + Network.networkName + '' }) dialog.open() @@ -115,11 +115,6 @@ ApplicationWindow id: _newWalletWizard NewWalletWizard { parent: Overlay.overlay - x: 12 - y: 12 - width: parent.width - 24 - height: parent.height - 24 - Overlay.modal: Rectangle { color: "#aa000000" } @@ -131,11 +126,6 @@ ApplicationWindow id: _serverConnectWizard ServerConnectWizard { parent: Overlay.overlay - x: 12 - y: 12 - width: parent.width - 24 - height: parent.height - 24 - Overlay.modal: Rectangle { color: "#aa000000" } @@ -150,9 +140,12 @@ ApplicationWindow modal: true x: (parent.width - width) / 2 y: (parent.height - height) / 2 + Overlay.modal: Rectangle { + color: "#aa000000" + } - title: "Message" - property alias message: messageLabel.text + title: qsTr("Message") + property alias text: messageLabel.text Label { id: messageLabel text: "Lorem ipsum dolor sit amet..." @@ -162,8 +155,8 @@ ApplicationWindow } Component.onCompleted: { - //Daemon.load_wallet() splashTimer.start() + if (!Config.autoConnectDefined) { var dialog = serverConnectWizard.createObject(app) // without completed serverConnectWizard we can't start @@ -172,6 +165,8 @@ ApplicationWindow Qt.callLater(Qt.quit) }) dialog.open() + } else { + Daemon.load_wallet() } } @@ -180,4 +175,32 @@ ApplicationWindow app.header.visible = false mainStackView.clear() } +/* OpenWallet as a popup dialog attempt + Component { + id: _openWallet + Dialog { + parent: Overlay.overlay + modal: true + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + Overlay.modal: Rectangle { + color: "#aa000000" + } + + title: qsTr("OpenWallet") + OpenWallet { + path: Daemon.path + } + + } + } +*/ + Connections { + target: Daemon + function onWalletRequiresPassword() { + app.stack.push(Qt.resolvedUrl("OpenWallet.qml"), {"path": Daemon.path}) +// var dialog = _openWallet.createObject(app) + //dialog.open() + } + } } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 91130f42e..1a4751efa 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -82,15 +82,14 @@ class QEDaemon(QObject): _logger = get_logger(__name__) _loaded_wallets = QEWalletListModel() _available_wallets = None + _current_wallet = None + _path = None walletLoaded = pyqtSignal() walletRequiresPassword = pyqtSignal() - activeWalletsChanged = pyqtSignal() availableWalletsChanged = pyqtSignal() - _current_wallet = None - @pyqtSlot() @pyqtSlot(str) @pyqtSlot(str, str) @@ -98,15 +97,24 @@ class QEDaemon(QObject): self._logger.debug('load wallet ' + str(path)) if path == None: path = self.daemon.config.get('gui_last_wallet') - wallet = self.daemon.load_wallet(path, password) + self._path = path + self._logger.debug('load wallet #2 ' + str(path)) + if path is not None: + wallet = self.daemon.load_wallet(path, password) + self._logger.debug('load wallet #3 ' + str(path)) if wallet != None: self._loaded_wallets.add_wallet(wallet=wallet) self._current_wallet = QEWallet(wallet) self.walletLoaded.emit() + self.daemon.config.save_last_wallet(wallet) else: self._logger.info('fail open wallet') self.walletRequiresPassword.emit() + @pyqtProperty('QString') + def path(self): + return self._path + @pyqtProperty(QEWallet, notify=walletLoaded) def currentWallet(self): return self._current_wallet diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index 00f7fa1a9..fe0d3bb16 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -200,13 +200,6 @@ class QEWalletDB(QObject): self._ready = True self.readyChanged.emit() - self.daemon.load_wallet(self._path, self._password) - - #wallet = Wallet(db, storage, config=self.config) - #wallet.start_network(self.network) - #self._wallets[path] = wallet - #return wallet - @pyqtSlot('QJSValue') def create_storage(self, js_data): self._logger.info('Creating wallet from wizard data') From 49b7a7518c8baee74757aef1dbad637dd2ab266d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 10 Mar 2022 12:48:17 +0100 Subject: [PATCH 045/218] upgrade wallet automatically when needed --- electrum/gui/qml/components/OpenWallet.qml | 25 ++++------------------ 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml index 47f2ade94..04dbb838d 100644 --- a/electrum/gui/qml/components/OpenWallet.qml +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -85,27 +85,6 @@ Pane { text: qsTr('Split wallet') onClicked: wallet_db.doSplit() } - - Label { - text: qsTr('Wallet requires upgrade') - visible: wallet_db.requiresUpgrade - } - - Button { - visible: wallet_db.requiresUpgrade - text: qsTr('Upgrade') - onClicked: wallet_db.doUpgrade() - } - - Rectangle { - Layout.columnSpan: 2 - Layout.alignment: Qt.AlignHCenter - visible: wallet_db.upgrading - width: 100 - height: 100 - color: "red" - } - } WalletDB { @@ -116,6 +95,10 @@ Pane { Daemon.availableWallets.reload() app.stack.pop() } + onRequiresUpgradeChanged: { + if (requiresUpgrade) + wallet_db.doUpgrade() + } onReadyChanged: { if (ready) { Daemon.load_wallet(Daemon.path, password.text) From c79414012c229cce53cc5b315dd075b40cc53271 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 10 Mar 2022 14:29:24 +0100 Subject: [PATCH 046/218] fix bug where undefined wallet instance crashes app --- electrum/gui/qml/components/main.qml | 1 + electrum/gui/qml/qedaemon.py | 25 +++++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 03852f8b5..7fe2e5156 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -198,6 +198,7 @@ ApplicationWindow Connections { target: Daemon function onWalletRequiresPassword() { + console.log('wallet requires password') app.stack.push(Qt.resolvedUrl("OpenWallet.qml"), {"path": Daemon.path}) // var dialog = _openWallet.createObject(app) //dialog.open() diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 1a4751efa..e98e7dd90 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -6,6 +6,7 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.util import register_callback, get_new_wallet_name from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet +from electrum.storage import WalletStorage from .qewallet import QEWallet @@ -89,26 +90,34 @@ class QEDaemon(QObject): walletRequiresPassword = pyqtSignal() activeWalletsChanged = pyqtSignal() availableWalletsChanged = pyqtSignal() + couldNotOpenFile = pyqtSignal() @pyqtSlot() @pyqtSlot(str) @pyqtSlot(str, str) def load_wallet(self, path=None, password=None): - self._logger.debug('load wallet ' + str(path)) if path == None: - path = self.daemon.config.get('gui_last_wallet') - self._path = path - self._logger.debug('load wallet #2 ' + str(path)) - if path is not None: - wallet = self.daemon.load_wallet(path, password) - self._logger.debug('load wallet #3 ' + str(path)) + self._path = self.daemon.config.get('gui_last_wallet') + else: + self._path = path + if self._path is None: + return + + self._logger.debug('load wallet ' + str(self._path)) + try: + storage = WalletStorage(self._path) + except StorageReadWriteError as e: + self.couldNotOpenFile.emit() + return + + wallet = self.daemon.load_wallet(self._path, password) if wallet != None: self._loaded_wallets.add_wallet(wallet=wallet) self._current_wallet = QEWallet(wallet) self.walletLoaded.emit() self.daemon.config.save_last_wallet(wallet) else: - self._logger.info('fail open wallet') + self._logger.info('password required but unset or incorrect') self.walletRequiresPassword.emit() @pyqtProperty('QString') From 7e1606fe864e284850f44db70321933f79eb65a2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 10 Mar 2022 20:35:14 +0100 Subject: [PATCH 047/218] validate seeds for Electrum, BIP39, SLIP39 seeds and perform create wallet in from seed scenario Currently only Electrum seeds are considered valid. For BIP39 additional dialog is needed. For SLIP39 multiple mnemonics need to be supported to generate a seed --- .../gui/qml/components/NewWalletWizard.qml | 2 + electrum/gui/qml/components/Wallets.qml | 2 + .../gui/qml/components/WizardComponents.qml | 85 +++++++++++++++---- electrum/gui/qml/qebitcoin.py | 82 +++++++++++++++--- electrum/gui/qml/qedaemon.py | 5 +- electrum/gui/qml/qewalletdb.py | 6 +- 6 files changed, 150 insertions(+), 32 deletions(-) diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index 8a0c13c21..37365ba8c 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -11,6 +11,8 @@ Wizard { signal walletCreated + property alias path: walletdb.path + enter: null // disable transition // State transition functions. These functions are called when the 'Next' diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 9877754e4..11c627ec0 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -107,6 +107,8 @@ Pane { dialog.open() dialog.walletCreated.connect(function() { Daemon.availableWallets.reload() + // and load the new wallet + Daemon.load_wallet(dialog.path, dialog.wizard_data['password']) }) } } diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index c8dd3df93..7110820ab 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -123,18 +123,18 @@ Item { function setWarningText(numwords) { var t = [ - "

", - qsTr("Please save these %1 words on paper (order is important). ").arg(numwords), - qsTr("This seed will allow you to recover your wallet in case of computer failure."), - "

", - "" + qsTr("WARNING") + ":", - "
    ", - "
  • " + qsTr("Never disclose your seed.") + "
  • ", - "
  • " + qsTr("Never type it on a website.") + "
  • ", - "
  • " + qsTr("Do not store it electronically.") + "
  • ", - "
" + '

', + qsTr('Please save these %1 words on paper (order is important).').arg(numwords), + qsTr('This seed will allow you to recover your wallet in case of computer failure.'), + '

', + '' + qsTr('WARNING') + ':', + '
    ', + '
  • ' + qsTr('Never disclose your seed.') + '
  • ', + '
  • ' + qsTr('Never type it on a website.') + '
  • ', + '
  • ' + qsTr('Do not store it electronically.') + '
  • ', + '
' ] - warningtext.text = t.join("") + warningtext.text = t.join(' ') } Flickable { @@ -187,7 +187,7 @@ Item { id: bitcoin onGeneratedSeedChanged: { seedtext.text = generated_seed - setWarningText(generated_seed.split(" ").length) + setWarningText(generated_seed.split(' ').length) } } } @@ -195,16 +195,16 @@ Item { property Component haveseed: Component { WizardComponent { + id: root valid: false onAccept: { wizard_data['seed'] = seedtext.text + wizard_data['seed_type'] = bitcoin.seed_type wizard_data['seed_extend'] = extendcb.checked wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' - wizard_data['seed_bip39'] = bip39cb.checked - } - - function checkValid() { + wizard_data['seed_bip39'] = seed_type.getTypeCode() == 'BIP39' + wizard_data['seed_slip39'] = seed_type.getTypeCode() == 'SLIP39' } function setSeedTypeHelpText() { @@ -230,6 +230,10 @@ Item { infotext.text = t[seed_type.currentText] } + function checkValid() { + bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39') + } + Flickable { anchors.fill: parent contentHeight: mainLayout.height @@ -243,11 +247,18 @@ Item { Label { text: qsTr('Seed Type') + Layout.fillWidth: true } ComboBox { id: seed_type model: ['Electrum', 'BIP39', 'SLIP39'] - onActivated: setSeedTypeHelpText() + onActivated: { + setSeedTypeHelpText() + checkValid() + } + function getTypeCode() { + return currentText + } } InfoTextArea { id: infotext @@ -263,9 +274,36 @@ Item { Layout.fillWidth: true Layout.columnSpan: 2 onTextChanged: { - checkValid() + validationTimer.restart() + } + + Rectangle { + anchors.fill: contentText + color: 'green' + border.color: Material.accentColor + radius: 2 + } + Label { + id: contentText + anchors.right: parent.right + anchors.bottom: parent.bottom + leftPadding: text != '' ? 16 : 0 + rightPadding: text != '' ? 16 : 0 + font.bold: false + font.pixelSize: 13 } } + TextArea { + id: validationtext + visible: text != '' + Layout.fillWidth: true + readOnly: true + wrapMode: TextInput.WordWrap + background: Rectangle { + color: 'transparent' + } + } + CheckBox { id: extendcb Layout.columnSpan: 2 @@ -284,7 +322,18 @@ Item { Bitcoin { id: bitcoin + onSeedTypeChanged: contentText.text = bitcoin.seed_type + onSeedValidChanged: root.valid = bitcoin.seed_valid + onValidationMessageChanged: validationtext.text = bitcoin.validation_message } + + Timer { + id: validationTimer + interval: 500 + repeat: false + onTriggered: checkValid() + } + Component.onCompleted: { setSeedTypeHelpText() } diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index d4f602235..16ed23a05 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -3,6 +3,8 @@ import asyncio from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger +from electrum.keystore import bip39_is_checksum_valid +from electrum.slip39 import decode_mnemonic, Slip39Error from electrum import mnemonic class QEBitcoin(QObject): @@ -18,10 +20,28 @@ class QEBitcoin(QObject): seedValidChanged = pyqtSignal() seedValid = False + seedTypeChanged = pyqtSignal() + seedType = '' + + validationMessageChanged = pyqtSignal() + validationMessage = '' + @pyqtProperty('QString', notify=generatedSeedChanged) def generated_seed(self): return self.generatedSeed + @pyqtProperty(bool, notify=seedValidChanged) + def seed_valid(self): + return self.seedValid + + @pyqtProperty('QString', notify=seedTypeChanged) + def seed_type(self): + return self.seedType + + @pyqtProperty('QString', notify=validationMessageChanged) + def validation_message(self): + return self.validationMessage + @pyqtSlot() @pyqtSlot(str) @pyqtSlot(str,str) @@ -36,17 +56,55 @@ class QEBitcoin(QObject): loop = asyncio.get_event_loop() asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), loop) - @pyqtProperty(bool, notify=seedValidChanged) - def seed_valid(self): - return self.seedValid - @pyqtSlot(str) - @pyqtSlot(str,str) - @pyqtSlot(str,str,str) - @pyqtSlot(str,str,str,str) - def verify_seed(self, seed, bip39=False, seed_type='segwit', language='en'): - self._logger.debug('verify seed of type ' + str(seed_type)) - #TODO - #self._logger.debug('seed verified') - #self.seedValidChanged.emit() + @pyqtSlot(str,bool,bool) + @pyqtSlot(str,bool,bool,str,str,str) + def verify_seed(self, seed, bip39=False, slip39=False, wallet_type='standard', language='en'): + self._logger.debug('bip39 ' + str(bip39)) + self._logger.debug('slip39 ' + str(slip39)) + + seed_type = '' + seed_valid = False + validation_message = '' + + if not (bip39 or slip39): + seed_type = mnemonic.seed_type(seed) + if seed_type != '': + seed_valid = True + elif bip39: + is_checksum, is_wordlist = bip39_is_checksum_valid(seed) + status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' + validation_message = 'BIP39 (%s)' % status + + if is_checksum: + seed_type = 'bip39' + seed_valid = True + seed_valid = False # for now + + elif slip39: # TODO: incomplete impl, this code only validates a single share. + try: + share = decode_mnemonic(seed) + seed_type = 'slip39' + validation_message = 'SLIP39: share #%d in %dof%d scheme' % (share.group_index, share.group_threshold, share.group_count) + except Slip39Error as e: + validation_message = 'SLIP39: %s' % str(e) + seed_valid = False # for now + + # cosigning seed + if wallet_type != 'standard' and seed_type not in ['standard', 'segwit']: + seed_type = '' + seed_valid = False + + self.seedType = seed_type + self.seedTypeChanged.emit() + + if self.validationMessage != validation_message: + self.validationMessage = validation_message + self.validationMessageChanged.emit() + + if self.seedValid != seed_valid: + self.seedValid = seed_valid + self.seedValidChanged.emit() + + self._logger.debug('seed verified: ' + str(seed_valid)) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index e98e7dd90..8feeab858 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -6,7 +6,7 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.util import register_callback, get_new_wallet_name from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet -from electrum.storage import WalletStorage +from electrum.storage import WalletStorage, StorageReadWriteError from .qewallet import QEWallet @@ -106,6 +106,9 @@ class QEDaemon(QObject): self._logger.debug('load wallet ' + str(self._path)) try: storage = WalletStorage(self._path) + if not storage.file_exists(): + self.couldNotOpenFile.emit() + return except StorageReadWriteError as e: self.couldNotOpenFile.emit() return diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index fe0d3bb16..7a638860f 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -59,8 +59,8 @@ class QEWalletDB(QObject): if wallet_path == self._path: return + self._logger.info('setting path: ' + wallet_path) self.reset() - self._logger.warning('path: ' + wallet_path) self._path = wallet_path self.load_storage() @@ -229,6 +229,10 @@ class QEWalletDB(QObject): db.load_plugins() db.write(storage) + # minimally populate self after create + self._password = data['password'] + self.path = path + self.createSuccess.emit() except Exception as e: self._logger.error(str(e)) From 17820b9346663a157d3be8d06ea2c3320acfa7cb Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 11 Mar 2022 13:19:51 +0100 Subject: [PATCH 048/218] add QEAddressListModel and initial Addresses.qml page. show sane main view when no wallet loaded. show error dialog when wallet could not be loaded. show wallet up_to_date indicator in title bar. refactor QETransactionListModel to be more self-contained. --- electrum/gui/qml/components/Addresses.qml | 88 ++++++++++++++ .../gui/qml/components/WalletMainView.qml | 27 ++++- electrum/gui/qml/components/main.qml | 16 ++- electrum/gui/qml/qedaemon.py | 30 +++-- electrum/gui/qml/qewallet.py | 113 ++++++++++++++---- 5 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 electrum/gui/qml/components/Addresses.qml diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml new file mode 100644 index 000000000..c2b57a79e --- /dev/null +++ b/electrum/gui/qml/components/Addresses.qml @@ -0,0 +1,88 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +Pane { + id: rootItem + anchors.fill: parent + + property string title: Daemon.walletName + ' - ' + qsTr('Addresses') + + ColumnLayout { + id: layout + width: parent.width + height: parent.height + + Item { + width: parent.width + Layout.fillHeight: true + + ListView { + id: listview + width: parent.width + height: parent.height + clip: true + model: Daemon.currentWallet.addressModel + + delegate: AbstractButton { + id: delegate + width: ListView.view.width + height: 30 + + background: Rectangle { + color: model.held ? Qt.rgba(1,0,0,0.5) : + model.numtx > 0 && model.balance == 0 ? Qt.rgba(1,1,1,0.25) : + model.type == 'receive' ? Qt.rgba(0,1,0,0.25) : + Qt.rgba(1,0.93,0,0.25) + Rectangle { + height: 1 + width: parent.width + anchors.top: parent.top + border.color: Material.accentColor + visible: model.index > 0 + } + } + RowLayout { + x: 10 + spacing: 5 + width: parent.width - 20 + anchors.verticalCenter: parent.verticalCenter + + Label { + font.pixelSize: 12 + text: model.type + } + Label { + font.pixelSize: 12 + font.family: "Courier" // TODO: use system monospace font + text: model.address + elide: Text.ElideMiddle + Layout.maximumWidth: delegate.width / 4 + } + Label { + font.pixelSize: 12 + text: model.label + elide: Text.ElideRight + Layout.minimumWidth: delegate.width / 4 + Layout.fillWidth: true + } + Label { + font.pixelSize: 12 + text: model.balance + } + Label { + font.pixelSize: 12 + text: model.numtx + } + } + } + } + + } + } + + Component.onCompleted: Daemon.currentWallet.addressModel.init_model() +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index a5388a183..06291f438 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -9,12 +9,35 @@ Item { property string title: Daemon.walletName property QtObject menu: Menu { - MenuItem { text: 'Wallets'; onTriggered: stack.push(Qt.resolvedUrl('Wallets.qml')) } - MenuItem { text: 'Network'; onTriggered: stack.push(Qt.resolvedUrl('NetworkStats.qml')) } + MenuItem { text: qsTr('Addresses'); onTriggered: stack.push(Qt.resolvedUrl('Addresses.qml')); visible: Daemon.currentWallet != null } + MenuItem { text: qsTr('Wallets'); onTriggered: stack.push(Qt.resolvedUrl('Wallets.qml')) } + MenuItem { text: qsTr('Network'); onTriggered: stack.push(Qt.resolvedUrl('NetworkStats.qml')) } + } + + ColumnLayout { + anchors.centerIn: parent + width: parent.width + spacing: 40 + visible: Daemon.currentWallet == null + + Label { + text: qsTr('No wallet loaded') + font.pixelSize: 24 + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: qsTr('Open/Create Wallet') + Layout.alignment: Qt.AlignHCenter + onClicked: { + stack.push(Qt.resolvedUrl('Wallets.qml')) + } + } } ColumnLayout { anchors.fill: parent + visible: Daemon.currentWallet != null TabBar { id: tabbar diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 7fe2e5156..2f9bdaa64 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -31,6 +31,7 @@ ApplicationWindow onClicked: stack.pop() } Item { + visible: Network.isTestNet width: column.width height: column.height MouseArea { @@ -46,7 +47,6 @@ ApplicationWindow Column { id: column - visible: Network.isTestNet Image { anchors.horizontalCenter: parent.horizontalCenter width: 16 @@ -63,15 +63,22 @@ ApplicationWindow } } + Image { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + source: Daemon.currentWallet.isUptodate ? "../../icons/status_connected.png" : "../../icons/status_lagging.png" + } + Label { text: stack.currentItem.title elide: Label.ElideRight horizontalAlignment: Qt.AlignHCenter verticalAlignment: Qt.AlignVCenter Layout.fillWidth: true - font.pointSize: 10 + font.pixelSize: 14 font.bold: true } + ToolButton { text: qsTr("⋮") onClicked: { @@ -203,5 +210,10 @@ ApplicationWindow // var dialog = _openWallet.createObject(app) //dialog.open() } + function onWalletOpenError(error) { + console.log('wallet open error') + var dialog = app.messageDialog.createObject(app, {'text': error}) + dialog.open() + } } } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 8feeab858..ce6030e55 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -3,7 +3,7 @@ import os from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex -from electrum.util import register_callback, get_new_wallet_name +from electrum.util import register_callback, get_new_wallet_name, WalletFileException from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import WalletStorage, StorageReadWriteError @@ -90,7 +90,7 @@ class QEDaemon(QObject): walletRequiresPassword = pyqtSignal() activeWalletsChanged = pyqtSignal() availableWalletsChanged = pyqtSignal() - couldNotOpenFile = pyqtSignal() + walletOpenError = pyqtSignal([str], arguments=["error"]) @pyqtSlot() @pyqtSlot(str) @@ -107,21 +107,25 @@ class QEDaemon(QObject): try: storage = WalletStorage(self._path) if not storage.file_exists(): - self.couldNotOpenFile.emit() + self.walletOpenError.emit(qsTr('File not found')) return except StorageReadWriteError as e: - self.couldNotOpenFile.emit() + self.walletOpenError.emit('Storage read/write error') return - wallet = self.daemon.load_wallet(self._path, password) - if wallet != None: - self._loaded_wallets.add_wallet(wallet=wallet) - self._current_wallet = QEWallet(wallet) - self.walletLoaded.emit() - self.daemon.config.save_last_wallet(wallet) - else: - self._logger.info('password required but unset or incorrect') - self.walletRequiresPassword.emit() + try: + wallet = self.daemon.load_wallet(self._path, password) + if wallet != None: + self._loaded_wallets.add_wallet(wallet=wallet) + self._current_wallet = QEWallet(wallet) + self.walletLoaded.emit() + self.daemon.config.save_last_wallet(wallet) + else: + self._logger.info('password required but unset or incorrect') + self.walletRequiresPassword.emit() + except WalletFileException as e: + self._logger.error(str(e)) + self.walletOpenError.emit(str(e)) @pyqtProperty('QString') def path(self): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 1ded4dc7b..060556c9a 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -8,9 +8,10 @@ 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 -class QETransactionsListModel(QAbstractListModel): - def __init__(self, parent=None): +class QETransactionListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): super().__init__(parent) + self.wallet = wallet self.tx_history = [] _logger = get_logger(__name__) @@ -43,20 +44,95 @@ class QETransactionsListModel(QAbstractListModel): self.endResetModel() # initial model data - def set_history(self, history): + def init_model(self): + history = self.wallet.get_detailed_history(show_addresses = True) + txs = history['transactions'] + # use primitives + for tx in txs: + for output in tx['outputs']: + output['value'] = output['value'].value + self.clear() - self.beginInsertRows(QModelIndex(), 0, len(history) - 1) - self.tx_history = history + self.beginInsertRows(QModelIndex(), 0, len(txs) - 1) + self.tx_history = txs self.tx_history.reverse() self.endInsertRows() +class QEAddressListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.receive_addresses = [] + self.change_addresses = [] + + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('type','address','label','balance','numtx', 'held') + _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.receive_addresses) + len(self.change_addresses) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + if index.row() > len(self.receive_addresses) - 1: + address = self.change_addresses[index.row() - len(self.receive_addresses)] + else: + address = self.receive_addresses[index.row()] + role_index = role - (Qt.UserRole + 1) + value = address[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.receive_addresses = [] + self.change_addresses = [] + self.endResetModel() + + # initial model data + @pyqtSlot() + def init_model(self): + r_addresses = self.wallet.get_receiving_addresses() + c_addresses = self.wallet.get_change_addresses() + n_addresses = len(r_addresses) + len(c_addresses) + + def insert_row(atype, alist, address): + item = {} + item['type'] = atype + item['address'] = address + item['numtx'] = self.wallet.get_address_history_len(address) + item['label'] = self.wallet.get_label(address) + c, u, x = self.wallet.get_addr_balance(address) + item['balance'] = c + u + x + item['held'] = self.wallet.is_frozen_address(address) + alist.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, n_addresses - 1) + for address in r_addresses: + insert_row('receive', self.receive_addresses, address) + for address in c_addresses: + insert_row('change', self.change_addresses, address) + self.endInsertRows() + class QEWallet(QObject): def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet - self._historyModel = QETransactionsListModel() - self.get_history() + self._historyModel = QETransactionListModel(wallet) + self._addressModel = QEAddressListModel(wallet) + self._historyModel.init_model() register_callback(self.on_request_status, ['request_status']) + register_callback(self.on_status, ['status']) _logger = get_logger(__name__) @@ -65,15 +141,18 @@ class QEWallet(QObject): requestStatus = pyqtSignal() def on_request_status(self, event, *args): self._logger.debug(str(event)) -# (wallet, addr, status) = args -# self._historyModel.add_tx() self.requestStatus.emit() historyModelChanged = pyqtSignal() - @pyqtProperty(QETransactionsListModel, notify=historyModelChanged) + @pyqtProperty(QETransactionListModel, notify=historyModelChanged) def historyModel(self): return self._historyModel + addressModelChanged = pyqtSignal() + @pyqtProperty(QEAddressListModel, notify=addressModelChanged) + def addressModel(self): + return self._addressModel + @pyqtProperty('QString', notify=dataChanged) def txinType(self): return self.wallet.get_txin_type(self.wallet.dummy_address()) @@ -115,22 +194,16 @@ class QEWallet(QObject): return c+x + def on_status(self, status): + self._logger.info('wallet: status update: ' + str(status)) + self.isUptodateChanged.emit() + # lightning feature? isUptodateChanged = pyqtSignal() @pyqtProperty(bool, notify=isUptodateChanged) def isUptodate(self): return self.wallet.is_up_to_date() - def get_history(self): - history = self.wallet.get_detailed_history(show_addresses = True) - txs = history['transactions'] - # use primitives - for tx in txs: - for output in tx['outputs']: - output['value'] = output['value'].value - self._historyModel.set_history(txs) - self.historyModelChanged.emit() - @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): self._logger.info('send_onchain: ' + address + ' ' + str(amount)) From bbd0ff8b91fd3cb8526154ccb597ca70dfa49f0f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 11 Mar 2022 15:44:08 +0100 Subject: [PATCH 049/218] move wizard components to separate files, add initial bip39 refine page --- .../gui/qml/components/NewWalletWizard.qml | 14 + .../qml/components/ServerConnectWizard.qml | 170 +------- .../gui/qml/components/WizardComponents.qml | 407 +----------------- .../qml/components/wizard/WCAutoConnect.qml | 40 ++ .../qml/components/wizard/WCBIP39Refine.qml | 80 ++++ .../qml/components/wizard/WCConfirmSeed.qml | 59 +++ .../qml/components/wizard/WCCreateSeed.qml | 88 ++++ .../gui/qml/components/wizard/WCHaveSeed.qml | 151 +++++++ .../qml/components/wizard/WCKeystoreType.qml | 43 ++ .../qml/components/wizard/WCProxyConfig.qml | 94 ++++ .../qml/components/wizard/WCServerConfig.qml | 42 ++ .../qml/components/wizard/WCWalletName.qml | 18 + .../components/wizard/WCWalletPassword.qml | 29 ++ .../qml/components/wizard/WCWalletType.qml | 43 ++ .../qml/components/{ => wizard}/Wizard.qml | 2 +- .../{ => wizard}/WizardComponent.qml | 0 electrum/gui/qml/qebitcoin.py | 1 - 17 files changed, 719 insertions(+), 562 deletions(-) create mode 100644 electrum/gui/qml/components/wizard/WCAutoConnect.qml create mode 100644 electrum/gui/qml/components/wizard/WCBIP39Refine.qml create mode 100644 electrum/gui/qml/components/wizard/WCConfirmSeed.qml create mode 100644 electrum/gui/qml/components/wizard/WCCreateSeed.qml create mode 100644 electrum/gui/qml/components/wizard/WCHaveSeed.qml create mode 100644 electrum/gui/qml/components/wizard/WCKeystoreType.qml create mode 100644 electrum/gui/qml/components/wizard/WCProxyConfig.qml create mode 100644 electrum/gui/qml/components/wizard/WCServerConfig.qml create mode 100644 electrum/gui/qml/components/wizard/WCWalletName.qml create mode 100644 electrum/gui/qml/components/wizard/WCWalletPassword.qml create mode 100644 electrum/gui/qml/components/wizard/WCWalletType.qml rename electrum/gui/qml/components/{ => wizard}/Wizard.qml (98%) rename electrum/gui/qml/components/{ => wizard}/WizardComponent.qml (100%) diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index 37365ba8c..3a82c5918 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -4,6 +4,8 @@ import QtQuick.Controls 2.1 import org.electrum 1.0 +import "wizard" + Wizard { id: walletwizard @@ -63,6 +65,18 @@ Wizard { function haveseedDone(d) { console.log('have seed done') + if (wizard_data['seed_type'] == 'bip39') { + var page = _loadNextComponent(components.bip39refine, wizard_data) + page.next.connect(function() {bip39refineDone()}) + } else { + var page = _loadNextComponent(components.walletpassword, wizard_data) + page.next.connect(function() {walletpasswordDone()}) + page.last = true + } + } + + function bip39refineDone(d) { + console.log('bip39 refine done') var page = _loadNextComponent(components.walletpassword, wizard_data) page.next.connect(function() {walletpasswordDone()}) page.last = true diff --git a/electrum/gui/qml/components/ServerConnectWizard.qml b/electrum/gui/qml/components/ServerConnectWizard.qml index d29b484df..0f80d47c7 100644 --- a/electrum/gui/qml/components/ServerConnectWizard.qml +++ b/electrum/gui/qml/components/ServerConnectWizard.qml @@ -2,6 +2,8 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.3 +import "wizard" + Wizard { id: serverconnectwizard @@ -37,177 +39,15 @@ Wizard { } property Component autoconnect: Component { - WizardComponent { - valid: true - last: serverconnectgroup.checkedButton.connecttype === 'auto' - - onAccept: { - wizard_data['autoconnect'] = serverconnectgroup.checkedButton.connecttype === 'auto' - } - - ColumnLayout { - width: parent.width - - InfoTextArea { - text: qsTr('Electrum communicates with remote servers to get information about your transactions and addresses. The servers all fulfill the same purpose only differing in hardware. In most cases you simply want to let Electrum pick one at random. However if you prefer feel free to select a server manually.') - Layout.fillWidth: true - } - - ButtonGroup { - id: serverconnectgroup - } - - RadioButton { - ButtonGroup.group: serverconnectgroup - property string connecttype: 'auto' - text: qsTr('Auto connect') - } - RadioButton { - ButtonGroup.group: serverconnectgroup - property string connecttype: 'manual' - checked: true - text: qsTr('Select servers manually') - } - - } - - } + WCAutoConnect {} } property Component proxyconfig: Component { - WizardComponent { - valid: true - - onAccept: { - var p = {} - p['enabled'] = proxy_enabled.checked - if (proxy_enabled.checked) { - var type = proxytype.currentValue.toLowerCase() - if (type == 'tor') - type = 'socks5' - p['mode'] = type - p['host'] = address.text - p['port'] = port.text - p['user'] = username.text - p['password'] = password.text - } - wizard_data['proxy'] = p - } - - ColumnLayout { - width: parent.width - - Label { - text: qsTr('Proxy settings') - } - - CheckBox { - id: proxy_enabled - text: qsTr('Enable Proxy') - } - - ComboBox { - id: proxytype - enabled: proxy_enabled.checked - model: ['TOR', 'SOCKS5', 'SOCKS4'] - onCurrentIndexChanged: { - if (currentIndex == 0) { - address.text = "127.0.0.1" - port.text = "9050" - } - } - } - - GridLayout { - columns: 4 - Layout.fillWidth: true - - Label { - text: qsTr("Address") - enabled: address.enabled - } - - TextField { - id: address - enabled: proxytype.enabled && proxytype.currentIndex > 0 - } - - Label { - text: qsTr("Port") - enabled: port.enabled - } - - TextField { - id: port - enabled: proxytype.enabled && proxytype.currentIndex > 0 - } - - Label { - text: qsTr("Username") - enabled: username.enabled - } - - TextField { - id: username - enabled: proxytype.enabled && proxytype.currentIndex > 0 - } - - Label { - text: qsTr("Password") - enabled: password.enabled - } - - TextField { - id: password - enabled: proxytype.enabled && proxytype.currentIndex > 0 - echoMode: TextInput.Password - } - } - } - - } + WCProxyConfig {} } property Component serverconfig: Component { - WizardComponent { - valid: true - last: true - - onAccept: { - wizard_data['oneserver'] = !auto_server.checked - wizard_data['server'] = address.text - } - - ColumnLayout { - width: parent.width - - Label { - text: qsTr('Server settings') - } - - CheckBox { - id: auto_server - text: qsTr('Select server automatically') - checked: true - } - - GridLayout { - columns: 2 - Layout.fillWidth: true - - Label { - text: qsTr("Server") - enabled: address.enabled - } - - TextField { - id: address - enabled: !auto_server.checked - } - } - } - - } + WCServerConfig {} } } diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml index 7110820ab..c6f910dad 100644 --- a/electrum/gui/qml/components/WizardComponents.qml +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -5,422 +5,39 @@ import QtQuick.Controls.Material 2.0 import org.electrum 1.0 +import "wizard" + Item { property Component walletname: Component { - WizardComponent { - valid: wallet_name.text.length > 0 - - onAccept: { - wizard_data['wallet_name'] = wallet_name.text - } - - GridLayout { - columns: 1 - Label { text: qsTr('Wallet name') } - TextField { - id: wallet_name - } - } - } + WCWalletName {} } property Component wallettype: Component { - WizardComponent { - valid: wallettypegroup.checkedButton !== null - - onAccept: { - wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype - } - - ButtonGroup { - id: wallettypegroup - } - - GridLayout { - columns: 1 - Label { text: qsTr('What kind of wallet do you want to create?') } - RadioButton { - ButtonGroup.group: wallettypegroup - property string wallettype: 'standard' - checked: true - text: qsTr('Standard Wallet') - } - RadioButton { - enabled: false - ButtonGroup.group: wallettypegroup - property string wallettype: '2fa' - text: qsTr('Wallet with two-factor authentication') - } - RadioButton { - enabled: false - ButtonGroup.group: wallettypegroup - property string wallettype: 'multisig' - text: qsTr('Multi-signature wallet') - } - RadioButton { - enabled: false - ButtonGroup.group: wallettypegroup - property string wallettype: 'import' - text: qsTr('Import Bitcoin addresses or private keys') - } - } - } + WCWalletType {} } property Component keystore: Component { - WizardComponent { - valid: keystoregroup.checkedButton !== null - - onAccept: { - wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype - } - - ButtonGroup { - id: keystoregroup - } - - GridLayout { - columns: 1 - Label { text: qsTr('What kind of wallet do you want to create?') } - RadioButton { - ButtonGroup.group: keystoregroup - property string keystoretype: 'createseed' - checked: true - text: qsTr('Create a new seed') - } - RadioButton { - ButtonGroup.group: keystoregroup - property string keystoretype: 'haveseed' - text: qsTr('I already have a seed') - } - RadioButton { - enabled: false - ButtonGroup.group: keystoregroup - property string keystoretype: 'masterkey' - text: qsTr('Use a master key') - } - RadioButton { - enabled: false - ButtonGroup.group: keystoregroup - property string keystoretype: 'hardware' - text: qsTr('Use a hardware device') - } - } - } - + WCKeystoreType {} } property Component createseed: Component { - WizardComponent { - valid: seedtext.text != '' - - onAccept: { - wizard_data['seed'] = seedtext.text - wizard_data['seed_type'] = 'segwit' - wizard_data['seed_extend'] = extendcb.checked - wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' - } - - function setWarningText(numwords) { - var t = [ - '

', - qsTr('Please save these %1 words on paper (order is important).').arg(numwords), - qsTr('This seed will allow you to recover your wallet in case of computer failure.'), - '

', - '' + qsTr('WARNING') + ':', - '
    ', - '
  • ' + qsTr('Never disclose your seed.') + '
  • ', - '
  • ' + qsTr('Never type it on a website.') + '
  • ', - '
  • ' + qsTr('Do not store it electronically.') + '
  • ', - '
' - ] - warningtext.text = t.join(' ') - } - - Flickable { - anchors.fill: parent - contentHeight: mainLayout.height - clip:true - interactive: height < contentHeight - - GridLayout { - id: mainLayout - width: parent.width - columns: 1 - - InfoTextArea { - id: warningtext - Layout.fillWidth: true - iconStyle: InfoTextArea.IconStyle.Warn - } - Label { text: qsTr('Your wallet generation seed is:') } - SeedTextArea { - id: seedtext - readOnly: true - Layout.fillWidth: true - - BusyIndicator { - anchors.centerIn: parent - height: parent.height * 2/3 - visible: seedtext.text == '' - } - } - CheckBox { - id: extendcb - text: qsTr('Extend seed with custom words') - } - TextField { - id: customwordstext - visible: extendcb.checked - Layout.fillWidth: true - placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password - } - Component.onCompleted : { - setWarningText(12) - bitcoin.generate_seed() - } - } - } - - Bitcoin { - id: bitcoin - onGeneratedSeedChanged: { - seedtext.text = generated_seed - setWarningText(generated_seed.split(' ').length) - } - } - } + WCCreateSeed {} } property Component haveseed: Component { - WizardComponent { - id: root - valid: false - - onAccept: { - wizard_data['seed'] = seedtext.text - wizard_data['seed_type'] = bitcoin.seed_type - wizard_data['seed_extend'] = extendcb.checked - wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' - wizard_data['seed_bip39'] = seed_type.getTypeCode() == 'BIP39' - wizard_data['seed_slip39'] = seed_type.getTypeCode() == 'SLIP39' - } - - function setSeedTypeHelpText() { - var t = { - 'Electrum': [ - qsTr('Electrum seeds are the default seed type.'), - qsTr('If you are restoring from a seed previously created by Electrum, choose this option') - ].join(' '), - 'BIP39': [ - qsTr('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), - '

', - qsTr('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'), - qsTr('BIP39 seeds do not include a version number, which compromises compatibility with future software.'), - '

', - qsTr('We do not guarantee that BIP39 imports will always be supported in Electrum.') - ].join(' '), - 'SLIP39': [ - qsTr('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), - '

', - qsTr('However, we do not generate SLIP39 seeds.') - ].join(' ') - } - infotext.text = t[seed_type.currentText] - } - - function checkValid() { - bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39') - } - - Flickable { - anchors.fill: parent - contentHeight: mainLayout.height - clip:true - interactive: height < contentHeight - - GridLayout { - id: mainLayout - width: parent.width - columns: 2 - - Label { - text: qsTr('Seed Type') - Layout.fillWidth: true - } - ComboBox { - id: seed_type - model: ['Electrum', 'BIP39', 'SLIP39'] - onActivated: { - setSeedTypeHelpText() - checkValid() - } - function getTypeCode() { - return currentText - } - } - InfoTextArea { - id: infotext - Layout.fillWidth: true - Layout.columnSpan: 2 - } - Label { - text: qsTr('Enter your seed') - Layout.columnSpan: 2 - } - SeedTextArea { - id: seedtext - Layout.fillWidth: true - Layout.columnSpan: 2 - onTextChanged: { - validationTimer.restart() - } - - Rectangle { - anchors.fill: contentText - color: 'green' - border.color: Material.accentColor - radius: 2 - } - Label { - id: contentText - anchors.right: parent.right - anchors.bottom: parent.bottom - leftPadding: text != '' ? 16 : 0 - rightPadding: text != '' ? 16 : 0 - font.bold: false - font.pixelSize: 13 - } - } - TextArea { - id: validationtext - visible: text != '' - Layout.fillWidth: true - readOnly: true - wrapMode: TextInput.WordWrap - background: Rectangle { - color: 'transparent' - } - } - - CheckBox { - id: extendcb - Layout.columnSpan: 2 - text: qsTr('Extend seed with custom words') - } - TextField { - id: customwordstext - visible: extendcb.checked - Layout.fillWidth: true - Layout.columnSpan: 2 - placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password - } - } - } - - Bitcoin { - id: bitcoin - onSeedTypeChanged: contentText.text = bitcoin.seed_type - onSeedValidChanged: root.valid = bitcoin.seed_valid - onValidationMessageChanged: validationtext.text = bitcoin.validation_message - } - - Timer { - id: validationTimer - interval: 500 - repeat: false - onTriggered: checkValid() - } - - Component.onCompleted: { - setSeedTypeHelpText() - } - } + WCHaveSeed {} } property Component confirmseed: Component { - WizardComponent { - valid: false - - function checkValid() { - var seedvalid = confirm.text == wizard_data['seed'] - var customwordsvalid = customwordstext.text == wizard_data['seed_extra_words'] - valid = seedvalid && (wizard_data['seed_extend'] ? customwordsvalid : true) - } - - Flickable { - anchors.fill: parent - contentHeight: mainLayout.height - clip:true - interactive: height < contentHeight - - GridLayout { - id: mainLayout - width: parent.width - columns: 1 - - InfoTextArea { - Layout.fillWidth: true - text: qsTr('Your seed is important!') + ' ' + - qsTr('If you lose your seed, your money will be permanently lost.') + ' ' + - qsTr('To make sure that you have properly saved your seed, please retype it here.') - } - Label { text: qsTr('Confirm your seed (re-enter)') } - SeedTextArea { - id: confirm - Layout.fillWidth: true - onTextChanged: { - checkValid() - } - } - TextField { - id: customwordstext - Layout.fillWidth: true - placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password - onTextChanged: { - checkValid() - } - } - } - } + WCConfirmSeed {} + } - onReadyChanged: { - if (ready) - customwordstext.visible = wizard_data['seed_extend'] - } - } + property Component bip39refine: Component { + WCBIP39Refine {} } property Component walletpassword: Component { - WizardComponent { - valid: password1.text === password2.text - - onAccept: { - wizard_data['password'] = password1.text - wizard_data['encrypt'] = doencrypt.checked - } - - GridLayout { - columns: 1 - Label { text: qsTr('Password protect wallet?') } - TextField { - id: password1 - echoMode: TextInput.Password - } - TextField { - id: password2 - echoMode: TextInput.Password - } - CheckBox { - id: doencrypt - enabled: password1.text !== '' - text: qsTr('Encrypt wallet') - } - } - } + WCWalletPassword {} } diff --git a/electrum/gui/qml/components/wizard/WCAutoConnect.qml b/electrum/gui/qml/components/wizard/WCAutoConnect.qml new file mode 100644 index 000000000..c06321036 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCAutoConnect.qml @@ -0,0 +1,40 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import ".." + +WizardComponent { + valid: true + last: serverconnectgroup.checkedButton.connecttype === 'auto' + + onAccept: { + wizard_data['autoconnect'] = serverconnectgroup.checkedButton.connecttype === 'auto' + } + + ColumnLayout { + width: parent.width + + InfoTextArea { + text: qsTr('Electrum communicates with remote servers to get information about your transactions and addresses. The servers all fulfill the same purpose only differing in hardware. In most cases you simply want to let Electrum pick one at random. However if you prefer feel free to select a server manually.') + Layout.fillWidth: true + } + + ButtonGroup { + id: serverconnectgroup + } + + RadioButton { + ButtonGroup.group: serverconnectgroup + property string connecttype: 'auto' + text: qsTr('Auto connect') + } + RadioButton { + ButtonGroup.group: serverconnectgroup + property string connecttype: 'manual' + checked: true + text: qsTr('Select servers manually') + } + + } + +} diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml new file mode 100644 index 000000000..44b5e744e --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -0,0 +1,80 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import ".." + +WizardComponent { + valid: false + + onAccept: { + } + + function setDerivationPath() { + var addrtype = { + 'p2pkh': 44, + 'p2wpkh-p2sh': 49, + 'p2wpkh': 84 + } + var nChain = Network.isTestNet ? 1 : 0 + derivationpathtext.text = + "m/" + addrtype[addresstypegroup.checkedButton.addresstype] + "'/" + + (Network.isTestNet ? 1 : 0) + "'/0'" + } + + ButtonGroup { + id: addresstypegroup + onCheckedButtonChanged: { + console.log('button changed: ' + checkedButton.addresstype) + setDerivationPath() + } + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + Label { text: qsTr('Script type and Derivation path') } + Button { + text: qsTr('Detect Existing Accounts') + enabled: false + } + Label { text: qsTr('Choose the type of addresses in your wallet.') } + RadioButton { + ButtonGroup.group: addresstypegroup + property string addresstype: 'p2pkh' + text: qsTr('legacy (p2pkh)') + } + RadioButton { + ButtonGroup.group: addresstypegroup + property string addresstype: 'p2wpkh-p2sh' + text: qsTr('wrapped segwit (p2wpkh-p2sh)') + } + RadioButton { + ButtonGroup.group: addresstypegroup + property string addresstype: 'p2wpkh' + checked: true + text: qsTr('native segwit (p2wpkh)') + } + InfoTextArea { + text: qsTr('You can override the suggested derivation path.') + ' ' + + qsTr('If you are not sure what this is, leave this field unchanged.') + } + TextField { + id: derivationpathtext + Layout.fillWidth: true + placeholderText: qsTr('Derivation path') + } + } + } +} + diff --git a/electrum/gui/qml/components/wizard/WCConfirmSeed.qml b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml new file mode 100644 index 000000000..5fb5b613c --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml @@ -0,0 +1,59 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import ".." + +WizardComponent { + valid: false + + function checkValid() { + var seedvalid = confirm.text == wizard_data['seed'] + var customwordsvalid = customwordstext.text == wizard_data['seed_extra_words'] + valid = seedvalid && (wizard_data['seed_extend'] ? customwordsvalid : true) + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + InfoTextArea { + Layout.fillWidth: true + text: qsTr('Your seed is important!') + ' ' + + qsTr('If you lose your seed, your money will be permanently lost.') + ' ' + + qsTr('To make sure that you have properly saved your seed, please retype it here.') + } + Label { text: qsTr('Confirm your seed (re-enter)') } + SeedTextArea { + id: confirm + Layout.fillWidth: true + onTextChanged: { + checkValid() + } + } + TextField { + id: customwordstext + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + onTextChanged: { + checkValid() + } + } + } + } + + onReadyChanged: { + if (ready) + customwordstext.visible = wizard_data['seed_extend'] + } +} diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml new file mode 100644 index 000000000..d62614aab --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -0,0 +1,88 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import ".." + +WizardComponent { + valid: seedtext.text != '' + + onAccept: { + wizard_data['seed'] = seedtext.text + wizard_data['seed_type'] = 'segwit' + wizard_data['seed_extend'] = extendcb.checked + wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' + } + + function setWarningText(numwords) { + var t = [ + '

', + qsTr('Please save these %1 words on paper (order is important).').arg(numwords), + qsTr('This seed will allow you to recover your wallet in case of computer failure.'), + '

', + '' + qsTr('WARNING') + ':', + '
    ', + '
  • ' + qsTr('Never disclose your seed.') + '
  • ', + '
  • ' + qsTr('Never type it on a website.') + '
  • ', + '
  • ' + qsTr('Do not store it electronically.') + '
  • ', + '
' + ] + warningtext.text = t.join(' ') + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + InfoTextArea { + id: warningtext + Layout.fillWidth: true + iconStyle: InfoTextArea.IconStyle.Warn + } + Label { text: qsTr('Your wallet generation seed is:') } + SeedTextArea { + id: seedtext + readOnly: true + Layout.fillWidth: true + + BusyIndicator { + anchors.centerIn: parent + height: parent.height * 2/3 + visible: seedtext.text == '' + } + } + CheckBox { + id: extendcb + text: qsTr('Extend seed with custom words') + } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + } + Component.onCompleted : { + setWarningText(12) + bitcoin.generate_seed() + } + } + } + + Bitcoin { + id: bitcoin + onGeneratedSeedChanged: { + seedtext.text = generated_seed + setWarningText(generated_seed.split(' ').length) + } + } +} diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml new file mode 100644 index 000000000..87df6262b --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -0,0 +1,151 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import ".." + +WizardComponent { + id: root + valid: false + + onAccept: { + wizard_data['seed'] = seedtext.text + wizard_data['seed_type'] = bitcoin.seed_type + wizard_data['seed_extend'] = extendcb.checked + wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' + wizard_data['seed_bip39'] = seed_type.getTypeCode() == 'BIP39' + wizard_data['seed_slip39'] = seed_type.getTypeCode() == 'SLIP39' + } + + function setSeedTypeHelpText() { + var t = { + 'Electrum': [ + qsTr('Electrum seeds are the default seed type.'), + qsTr('If you are restoring from a seed previously created by Electrum, choose this option') + ].join(' '), + 'BIP39': [ + qsTr('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), + '

', + qsTr('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'), + qsTr('BIP39 seeds do not include a version number, which compromises compatibility with future software.') + ].join(' '), + 'SLIP39': [ + qsTr('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), + '

', + qsTr('However, we do not generate SLIP39 seeds.') + ].join(' ') + } + infotext.text = t[seed_type.currentText] + } + + function checkValid() { + bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39') + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Seed Type') + Layout.fillWidth: true + } + ComboBox { + id: seed_type + model: ['Electrum', 'BIP39', 'SLIP39'] + onActivated: { + setSeedTypeHelpText() + checkValid() + } + function getTypeCode() { + return currentText + } + } + InfoTextArea { + id: infotext + Layout.fillWidth: true + Layout.columnSpan: 2 + } + Label { + text: qsTr('Enter your seed') + Layout.columnSpan: 2 + } + SeedTextArea { + id: seedtext + Layout.fillWidth: true + Layout.columnSpan: 2 + onTextChanged: { + validationTimer.restart() + } + + Rectangle { + anchors.fill: contentText + color: 'green' + border.color: Material.accentColor + radius: 2 + } + Label { + id: contentText + anchors.right: parent.right + anchors.bottom: parent.bottom + leftPadding: text != '' ? 16 : 0 + rightPadding: text != '' ? 16 : 0 + font.bold: false + font.pixelSize: 13 + } + } + TextArea { + id: validationtext + visible: text != '' + Layout.fillWidth: true + readOnly: true + wrapMode: TextInput.WordWrap + background: Rectangle { + color: 'transparent' + } + } + + CheckBox { + id: extendcb + Layout.columnSpan: 2 + text: qsTr('Extend seed with custom words') + } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + Layout.columnSpan: 2 + placeholderText: qsTr('Enter your custom word(s)') + echoMode: TextInput.Password + } + } + } + + Bitcoin { + id: bitcoin + onSeedTypeChanged: contentText.text = bitcoin.seed_type + onSeedValidChanged: root.valid = bitcoin.seed_valid + onValidationMessageChanged: validationtext.text = bitcoin.validation_message + } + + Timer { + id: validationTimer + interval: 500 + repeat: false + onTriggered: checkValid() + } + + Component.onCompleted: { + setSeedTypeHelpText() + } +} diff --git a/electrum/gui/qml/components/wizard/WCKeystoreType.qml b/electrum/gui/qml/components/wizard/WCKeystoreType.qml new file mode 100644 index 000000000..2e180a640 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCKeystoreType.qml @@ -0,0 +1,43 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: keystoregroup.checkedButton !== null + + onAccept: { + wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype + } + + ButtonGroup { + id: keystoregroup + } + + GridLayout { + columns: 1 + Label { text: qsTr('What kind of wallet do you want to create?') } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'createseed' + checked: true + text: qsTr('Create a new seed') + } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'haveseed' + text: qsTr('I already have a seed') + } + RadioButton { + enabled: false + ButtonGroup.group: keystoregroup + property string keystoretype: 'masterkey' + text: qsTr('Use a master key') + } + RadioButton { + enabled: false + ButtonGroup.group: keystoregroup + property string keystoretype: 'hardware' + text: qsTr('Use a hardware device') + } + } +} + diff --git a/electrum/gui/qml/components/wizard/WCProxyConfig.qml b/electrum/gui/qml/components/wizard/WCProxyConfig.qml new file mode 100644 index 000000000..bf1c62b77 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCProxyConfig.qml @@ -0,0 +1,94 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: true + + onAccept: { + var p = {} + p['enabled'] = proxy_enabled.checked + if (proxy_enabled.checked) { + var type = proxytype.currentValue.toLowerCase() + if (type == 'tor') + type = 'socks5' + p['mode'] = type + p['host'] = address.text + p['port'] = port.text + p['user'] = username.text + p['password'] = password.text + } + wizard_data['proxy'] = p + } + + ColumnLayout { + width: parent.width + + Label { + text: qsTr('Proxy settings') + } + + CheckBox { + id: proxy_enabled + text: qsTr('Enable Proxy') + } + + ComboBox { + id: proxytype + enabled: proxy_enabled.checked + model: ['TOR', 'SOCKS5', 'SOCKS4'] + onCurrentIndexChanged: { + if (currentIndex == 0) { + address.text = "127.0.0.1" + port.text = "9050" + } + } + } + + GridLayout { + columns: 4 + Layout.fillWidth: true + + Label { + text: qsTr("Address") + enabled: address.enabled + } + + TextField { + id: address + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Port") + enabled: port.enabled + } + + TextField { + id: port + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Username") + enabled: username.enabled + } + + TextField { + id: username + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Password") + enabled: password.enabled + } + + TextField { + id: password + enabled: proxytype.enabled && proxytype.currentIndex > 0 + echoMode: TextInput.Password + } + } + } + +} diff --git a/electrum/gui/qml/components/wizard/WCServerConfig.qml b/electrum/gui/qml/components/wizard/WCServerConfig.qml new file mode 100644 index 000000000..8bad45136 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCServerConfig.qml @@ -0,0 +1,42 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: true + last: true + + onAccept: { + wizard_data['oneserver'] = !auto_server.checked + wizard_data['server'] = address.text + } + + ColumnLayout { + width: parent.width + + Label { + text: qsTr('Server settings') + } + + CheckBox { + id: auto_server + text: qsTr('Select server automatically') + checked: true + } + + GridLayout { + columns: 2 + Layout.fillWidth: true + + Label { + text: qsTr("Server") + enabled: address.enabled + } + + TextField { + id: address + enabled: !auto_server.checked + } + } + } + +} diff --git a/electrum/gui/qml/components/wizard/WCWalletName.qml b/electrum/gui/qml/components/wizard/WCWalletName.qml new file mode 100644 index 000000000..c83d25fe4 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCWalletName.qml @@ -0,0 +1,18 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: wallet_name.text.length > 0 + + onAccept: { + wizard_data['wallet_name'] = wallet_name.text + } + + GridLayout { + columns: 1 + Label { text: qsTr('Wallet name') } + TextField { + id: wallet_name + } + } +} diff --git a/electrum/gui/qml/components/wizard/WCWalletPassword.qml b/electrum/gui/qml/components/wizard/WCWalletPassword.qml new file mode 100644 index 000000000..9da3681ff --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCWalletPassword.qml @@ -0,0 +1,29 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: password1.text === password2.text + + onAccept: { + wizard_data['password'] = password1.text + wizard_data['encrypt'] = doencrypt.checked + } + + GridLayout { + columns: 1 + Label { text: qsTr('Password protect wallet?') } + TextField { + id: password1 + echoMode: TextInput.Password + } + TextField { + id: password2 + echoMode: TextInput.Password + } + CheckBox { + id: doencrypt + enabled: password1.text !== '' + text: qsTr('Encrypt wallet') + } + } +} diff --git a/electrum/gui/qml/components/wizard/WCWalletType.qml b/electrum/gui/qml/components/wizard/WCWalletType.qml new file mode 100644 index 000000000..e7c02dd61 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCWalletType.qml @@ -0,0 +1,43 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: wallettypegroup.checkedButton !== null + + onAccept: { + wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype + } + + ButtonGroup { + id: wallettypegroup + } + + GridLayout { + columns: 1 + Label { text: qsTr('What kind of wallet do you want to create?') } + RadioButton { + ButtonGroup.group: wallettypegroup + property string wallettype: 'standard' + checked: true + text: qsTr('Standard Wallet') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: '2fa' + text: qsTr('Wallet with two-factor authentication') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: 'multisig' + text: qsTr('Multi-signature wallet') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: 'import' + text: qsTr('Import Bitcoin addresses or private keys') + } + } +} diff --git a/electrum/gui/qml/components/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml similarity index 98% rename from electrum/gui/qml/components/Wizard.qml rename to electrum/gui/qml/components/wizard/Wizard.qml index c7c4a23a2..c87db3c98 100644 --- a/electrum/gui/qml/components/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -138,7 +138,7 @@ Dialog { rowSpacing: 0 Image { - source: "../../icons/electrum.png" + source: "../../../icons/electrum.png" Layout.preferredWidth: 48 Layout.preferredHeight: 48 Layout.leftMargin: 12 diff --git a/electrum/gui/qml/components/WizardComponent.qml b/electrum/gui/qml/components/wizard/WizardComponent.qml similarity index 100% rename from electrum/gui/qml/components/WizardComponent.qml rename to electrum/gui/qml/components/wizard/WizardComponent.qml diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 16ed23a05..f556c7bfe 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -79,7 +79,6 @@ class QEBitcoin(QObject): if is_checksum: seed_type = 'bip39' seed_valid = True - seed_valid = False # for now elif slip39: # TODO: incomplete impl, this code only validates a single share. try: From e329c54162e80361cef4ef90d90e3cb4bfe4e8f1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 16 Mar 2022 22:32:16 +0100 Subject: [PATCH 050/218] implement bip39 seed to wallet fix auto-upgrade wallet --- electrum/gui/qml/components/OpenWallet.qml | 4 -- electrum/gui/qml/components/Wallets.qml | 30 ++++++----- .../qml/components/wizard/WCBIP39Refine.qml | 44 +++++++++++----- electrum/gui/qml/qebitcoin.py | 4 ++ electrum/gui/qml/qewallet.py | 5 +- electrum/gui/qml/qewalletdb.py | 50 ++++++------------- 6 files changed, 71 insertions(+), 66 deletions(-) diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml index 04dbb838d..55b510cae 100644 --- a/electrum/gui/qml/components/OpenWallet.qml +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -95,10 +95,6 @@ Pane { Daemon.availableWallets.reload() app.stack.pop() } - onRequiresUpgradeChanged: { - if (requiresUpgrade) - wallet_db.doUpgrade() - } onReadyChanged: { if (ready) { Daemon.load_wallet(Daemon.path, password.text) diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 11c627ec0..f33f29d6a 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -1,6 +1,7 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 import org.electrum 1.0 @@ -25,25 +26,25 @@ Pane { columns: 4 Label { text: 'Wallet'; Layout.columnSpan: 2 } - Label { text: Daemon.walletName; Layout.columnSpan: 2 } + Label { text: Daemon.walletName; Layout.columnSpan: 2; color: Material.accentColor } + + Label { text: 'derivation path (BIP32)'; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } + Label { text: Daemon.currentWallet.derivationPath; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } Label { text: 'txinType' } - Label { text: Daemon.currentWallet.txinType } + Label { text: Daemon.currentWallet.txinType; color: Material.accentColor } Label { text: 'is deterministic' } - Label { text: Daemon.currentWallet.isDeterministic } + Label { text: Daemon.currentWallet.isDeterministic; color: Material.accentColor } Label { text: 'is watch only' } - Label { text: Daemon.currentWallet.isWatchOnly } + Label { text: Daemon.currentWallet.isWatchOnly; color: Material.accentColor } Label { text: 'is Encrypted' } - Label { text: Daemon.currentWallet.isEncrypted } + Label { text: Daemon.currentWallet.isEncrypted; color: Material.accentColor } Label { text: 'is Hardware' } - Label { text: Daemon.currentWallet.isHardware } - - Label { text: 'derivation path (BIP32)'; visible: Daemon.currentWallet.isDeterministic } - Label { text: Daemon.currentWallet.derivationPath; visible: Daemon.currentWallet.isDeterministic } + Label { text: Daemon.currentWallet.isHardware; color: Material.accentColor } } } // } @@ -75,16 +76,19 @@ Pane { } RowLayout { - x: 10 spacing: 10 - width: parent.width - 20 + width: parent.width Image { - source: "../../kivy/theming/light/wallet.png" + id: walleticon + source: "../../icons/wallet.png" + fillMode: Image.PreserveAspectFit + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 } Label { - font.pointSize: 11 + font.pixelSize: 18 text: model.name Layout.fillWidth: true } diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml index 44b5e744e..872d712a7 100644 --- a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -10,24 +10,36 @@ WizardComponent { valid: false onAccept: { + wizard_data['script_type'] = scripttypegroup.checkedButton.scripttype + wizard_data['derivation_path'] = derivationpathtext.text } - - function setDerivationPath() { - var addrtype = { + function getScriptTypePurposeDict() { + return { 'p2pkh': 44, 'p2wpkh-p2sh': 49, 'p2wpkh': 84 } - var nChain = Network.isTestNet ? 1 : 0 + } + + function validate() { + valid = false + if (!scripttypegroup.checkedButton.scripttype in getScriptTypePurposeDict()) + return + if (!bitcoin.verify_derivation_path(derivationpathtext.text)) + return + valid = true + } + + function setDerivationPath() { + var p = getScriptTypePurposeDict() derivationpathtext.text = - "m/" + addrtype[addresstypegroup.checkedButton.addresstype] + "'/" + "m/" + p[scripttypegroup.checkedButton.scripttype] + "'/" + (Network.isTestNet ? 1 : 0) + "'/0'" } ButtonGroup { - id: addresstypegroup + id: scripttypegroup onCheckedButtonChanged: { - console.log('button changed: ' + checkedButton.addresstype) setDerivationPath() } } @@ -50,18 +62,18 @@ WizardComponent { } Label { text: qsTr('Choose the type of addresses in your wallet.') } RadioButton { - ButtonGroup.group: addresstypegroup - property string addresstype: 'p2pkh' + ButtonGroup.group: scripttypegroup + property string scripttype: 'p2pkh' text: qsTr('legacy (p2pkh)') } RadioButton { - ButtonGroup.group: addresstypegroup - property string addresstype: 'p2wpkh-p2sh' + ButtonGroup.group: scripttypegroup + property string scripttype: 'p2wpkh-p2sh' text: qsTr('wrapped segwit (p2wpkh-p2sh)') } RadioButton { - ButtonGroup.group: addresstypegroup - property string addresstype: 'p2wpkh' + ButtonGroup.group: scripttypegroup + property string scripttype: 'p2wpkh' checked: true text: qsTr('native segwit (p2wpkh)') } @@ -73,8 +85,14 @@ WizardComponent { id: derivationpathtext Layout.fillWidth: true placeholderText: qsTr('Derivation path') + onTextChanged: validate() } } } + + Bitcoin { + id: bitcoin + } + } diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index f556c7bfe..545b8db66 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -4,6 +4,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger from electrum.keystore import bip39_is_checksum_valid +from electrum.bip32 import is_bip32_derivation from electrum.slip39 import decode_mnemonic, Slip39Error from electrum import mnemonic @@ -107,3 +108,6 @@ class QEBitcoin(QObject): self._logger.debug('seed verified: ' + str(seed_valid)) + @pyqtSlot(str, result=bool) + def verify_derivation_path(self, path): + return is_bip32_derivation(path) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 060556c9a..8682c95f5 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -175,7 +175,10 @@ class QEWallet(QObject): @pyqtProperty('QString', notify=dataChanged) def derivationPath(self): - return self.wallet.get_address_path_str(self.wallet.dummy_address()) + keystores = self.wallet.get_keystores() + if len(keystores) > 1: + self._logger.debug('multiple keystores not supported yet') + return keystores[0].get_derivation_prefix() balanceChanged = pyqtSignal() diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index 7a638860f..34000d77a 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -5,6 +5,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import Logger, get_logger from electrum.storage import WalletStorage, StorageEncryptionVersion from electrum.wallet_db import WalletDB +from electrum.bip32 import normalize_bip32_derivation from electrum.util import InvalidPassword from electrum import keystore @@ -28,8 +29,6 @@ class QEWalletDB(QObject): passwordChanged = pyqtSignal() invalidPasswordChanged = pyqtSignal() requiresSplitChanged = pyqtSignal() - requiresUpgradeChanged = pyqtSignal() - upgradingChanged = pyqtSignal() splitFinished = pyqtSignal() readyChanged = pyqtSignal() createError = pyqtSignal([str], arguments=["error"]) @@ -41,8 +40,6 @@ class QEWalletDB(QObject): self._needsHWDevice = False self._password = '' self._requiresSplit = False - self._requiresUpgrade = False - self._upgrading = False self._invalidPassword = False self._storage = None @@ -115,14 +112,6 @@ class QEWalletDB(QObject): def requiresSplit(self): return self._requiresSplit - @pyqtProperty(bool, notify=requiresUpgradeChanged) - def requiresUpgrade(self): - return self._requiresUpgrade - - @pyqtProperty(bool, notify=upgradingChanged) - def upgrading(self): - return self._upgrading - @pyqtProperty(bool, notify=invalidPasswordChanged) def invalidPassword(self): return self._invalidPassword @@ -142,23 +131,6 @@ class QEWalletDB(QObject): self.splitFinished.emit() - @pyqtSlot() - def doUpgrade(self): - self._logger.warning('doUpgrade') - if not self._requiresUpgrade: - return - - self._logger.warning('upgrading') - - self._upgrading = True - self.upgradingChanged.emit() - - self._db.upgrade() - self._db.write(self._storage) - - self._upgrading = False - self.upgradingChanged.emit() - def load_storage(self): self._storage = WalletStorage(self._path) if not self._storage.file_exists(): @@ -188,15 +160,15 @@ class QEWalletDB(QObject): self._requiresSplit = True self.requiresSplitChanged.emit() return - if self._db.requires_upgrade(): - self._logger.warning('requires upgrade') - self._requiresUpgrade = True - self.requiresUpgradeChanged.emit() - return if self._db.get_action(): self._logger.warning('action pending. QML version doesn\'t support continuation of wizard') return + if self._db.requires_upgrade(): + self._logger.warning('wallet requires upgrade, upgrading') + self._db.upgrade() + self._db.write(self._storage) + self._ready = True self.readyChanged.emit() @@ -212,7 +184,15 @@ class QEWalletDB(QObject): raise Exception('file already exists at path') storage = WalletStorage(path) - k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') + if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-segwit + self._logger.debug('creating keystore from electrum seed') + k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') + elif data['seed_type'] == 'bip39': + self._logger.debug('creating keystore from bip39 seed') + root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) + derivation = normalize_bip32_derivation(data['derivation_path']) + script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' + k = keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) if data['encrypt']: storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) From 4680521d079586794c544f052b6e03196e2bbae1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 16 Mar 2022 22:35:58 +0100 Subject: [PATCH 051/218] ui history page --- electrum/gui/qml/components/History.qml | 81 ++++++++----------------- 1 file changed, 26 insertions(+), 55 deletions(-) diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 625b13c48..0289d81eb 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -1,6 +1,7 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 import org.electrum 1.0 @@ -16,18 +17,6 @@ Pane { model: Daemon.currentWallet.historyModel - header: Item { - id: header - width: ListView.view.width - height: balance.height - - BalanceSummary { - id: balance - width: parent.width - } - - } - delegate: Item { id: delegate width: ListView.view.width @@ -40,23 +29,11 @@ Pane { GridLayout { id: txinfo - columns: 4 + columns: 3 x: 6 width: delegate.width - 12 - Item { - id: indicator - Layout.fillHeight: true - Layout.rowSpan: 2 - Rectangle { - width: 3 - color: model.incoming ? 'green' : 'red' - y: 2 - height: parent.height - 4 - } - } - Image { readonly property variant tx_icons : [ "../../../gui/icons/unconfirmed.png", @@ -68,38 +45,32 @@ Pane { "../../../gui/icons/confirmed.png" ] - sourceSize.width: 48 - sourceSize.height: 48 + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 Layout.alignment: Qt.AlignVCenter + Layout.rowSpan: 2 source: tx_icons[Math.min(6,model.confirmations)] } - Column { + Label { + font.pixelSize: 18 Layout.fillWidth: true - - Label { - font.pointSize: 12 - text: model.label !== '' ? model.label : '' - color: model.label !== '' ? 'black' : 'gray' - font.bold: model.label !== '' ? true : false - } - Label { - font.pointSize: 7 - text: model.date - } + text: model.label !== '' ? model.label : '' + color: model.label !== '' ? Material.accentColor : 'gray' } - - Column { - id: valuefee - Label { - font.pointSize: 12 - text: model.bc_value - font.bold: true - } - Label { - font.pointSize: 6 - text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') - } + Label { + font.pixelSize: 15 + text: model.bc_value + font.bold: true + color: model.incoming ? "#ff80ff80" : "#ffff8080" + } + Label { + font.pixelSize: 12 + text: model.date + } + Label { + font.pixelSize: 10 + text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') } GridLayout { @@ -110,24 +81,24 @@ Pane { Label { text: 'txid' } Label { - font.pointSize: 6 + font.pixelSize: 10 text: model.txid elide: Text.ElideMiddle Layout.fillWidth: true } Label { text: 'height' } Label { - font.pointSize: 7 + font.pixelSize: 10 text: model.height } Label { text: 'confirmations' } Label { - font.pointSize: 7 + font.pixelSize: 10 text: model.confirmations } Label { text: 'address' } Label { - font.pointSize: 7 + font.pixelSize: 10 elide: Text.ElideMiddle Layout.fillWidth: true text: { From 45f50d3078f268dbbe6d7504fbbbe14546d8245f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 16 Mar 2022 22:37:02 +0100 Subject: [PATCH 052/218] fixes --- electrum/gui/qml/components/wizard/WCWalletPassword.qml | 8 ++------ electrum/gui/qml/qedaemon.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/electrum/gui/qml/components/wizard/WCWalletPassword.qml b/electrum/gui/qml/components/wizard/WCWalletPassword.qml index 9da3681ff..51c5cc468 100644 --- a/electrum/gui/qml/components/wizard/WCWalletPassword.qml +++ b/electrum/gui/qml/components/wizard/WCWalletPassword.qml @@ -1,3 +1,4 @@ +import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 @@ -6,7 +7,7 @@ WizardComponent { onAccept: { wizard_data['password'] = password1.text - wizard_data['encrypt'] = doencrypt.checked + wizard_data['encrypt'] = password1.text != '' } GridLayout { @@ -20,10 +21,5 @@ WizardComponent { id: password2 echoMode: TextInput.Password } - CheckBox { - id: doencrypt - enabled: password1.text !== '' - text: qsTr('Encrypt wallet') - } } } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index ce6030e55..4402ee708 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -107,7 +107,7 @@ class QEDaemon(QObject): try: storage = WalletStorage(self._path) if not storage.file_exists(): - self.walletOpenError.emit(qsTr('File not found')) + self.walletOpenError.emit('File not found') return except StorageReadWriteError as e: self.walletOpenError.emit('Storage read/write error') From a2fac2e3e36330f31489ec8ca92de8ec63c3f182 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 16 Mar 2022 22:39:09 +0100 Subject: [PATCH 053/218] buildozer: exclude env dir, don't include vs, fs extensions --- contrib/android/buildozer_qml.spec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec index 0deda56b8..e83fd52c8 100644 --- a/contrib/android/buildozer_qml.spec +++ b/contrib/android/buildozer_qml.spec @@ -13,13 +13,13 @@ package.domain = org.electrum source.dir = . # (list) Source files to include (let empty to include all the files) -source.include_exts = py,png,jpg,qml,qmltypes,ttf,txt,gif,pem,mo,vs,fs,json,csv,so +source.include_exts = py,png,jpg,qml,qmltypes,ttf,txt,gif,pem,mo,json,csv,so # (list) Source files to exclude (let empty to not exclude anything) source.exclude_exts = spec # (list) List of directory to exclude (let empty to not exclude anything) -source.exclude_dirs = bin, build, dist, contrib, +source.exclude_dirs = bin, build, dist, contrib, env, electrum/tests, electrum/gui/qt, electrum/gui/kivy, From 8000327097f4746d26b2d934b6ead8b89f9752d6 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 17 Mar 2022 10:29:41 +0100 Subject: [PATCH 054/218] gui: copy wallet.png from kivy to gui/icons/ --- electrum/gui/icons/wallet.png | Bin 0 -> 824 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 electrum/gui/icons/wallet.png diff --git a/electrum/gui/icons/wallet.png b/electrum/gui/icons/wallet.png new file mode 100644 index 0000000000000000000000000000000000000000..d740fc7a83e6c4737000ce078359ff18c682a3e5 GIT binary patch literal 824 zcmV-81IPS{P)00004XF*Lt006O% z3;baP00006VoOIv0RI600RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliru z-3b&801qg5i7Eg903B&mSad^gZEa<4bN~PV002XBWnpw>WFU8GbZ8()Nlj2>E@cM* z00N9jL_t(&-tCz`XcR#dhrc(w_fL&61X7&dAtn$^Cuu~BC|HRW78YWoAw;$kY_t$e zktU6Wpr;3lg# zY~X}~&Lw-#|C5~>5xxdnnFSCHU`*}Ivo;R22bHEn1e%$0z^#tZeJ2l*$eo1NJegJQ z&7S)h_&Wd%h43{WPTX_HCc!orFglYzt}wj@Qkz{XG!lAZp0*WApBf6`DMlv2o_4ZH zZS1=W`au5ZIBbEGkq%(AVGxy&ncYR#1?a}Qh|srXZ-t~Anqn=BnU(SPPzEAd#TtXR zGEQ1%S5?~pyaL`v@}2+)B)nl4=3W8>PHxCTh9cKbEV4YYTK|_bqRWrj>G#?6tzAr=hE`X^r zMU5$Kb;+bvP1_BTlLFcjTm!Bg9e2yqPWF5^*6Gm5LH)bbHoBhR8PSiLVeBK7;rw8# z=sxHv%r67aq9v!Q1ctSfzwJBw_D8z26X>^5k3%Q-8DxPCc62K-FI-;xY}2%RLLr?% z=Voj1r6n>zdD}e{#1Jt=3=u=b5HZAtAyW3%OXPnUz6KW->q7XUyw)uYVJkCna@-B- zLZplCM<2%g79`P_(i}2cyQr-386WxI(>OadDSiMD-`fkPT42Ed0000 Date: Fri, 18 Mar 2022 15:00:29 +0100 Subject: [PATCH 055/218] qml: add QR code imageprovider using qrcode/PIL adds buildozer 'pillow' recipe to requirements add initial PoC on qml receive tab --- contrib/android/buildozer_qml.spec | 3 +- electrum/gui/qml/components/Receive.qml | 32 ++++++++++++ .../gui/qml/components/WalletMainView.qml | 8 ++- electrum/gui/qml/qeapp.py | 7 ++- electrum/gui/qml/qeqr.py | 51 +++++++++++++++---- 5 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 electrum/gui/qml/components/Receive.qml diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec index e83fd52c8..6eb434298 100644 --- a/contrib/android/buildozer_qml.spec +++ b/contrib/android/buildozer_qml.spec @@ -51,7 +51,8 @@ requirements = libsecp256k1, cryptography, pyqt5sip, - pyqt5 + pyqt5, + pillow # (str) Presplash of the application #presplash.filename = %(source.dir)s/gui/kivy/theming/splash.png diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml new file mode 100644 index 000000000..174428e58 --- /dev/null +++ b/electrum/gui/qml/components/Receive.qml @@ -0,0 +1,32 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +Pane { + id: rootItem + visible: Daemon.currentWallet !== undefined + + ColumnLayout { + width: parent.width + spacing: 20 + + Image { + id: img + } + + TextField { + id: text + } + + Button { + text: 'generate' + onClicked: { + img.source = 'image://qrgen/' + text.text + } + } + } + +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 06291f438..a096c1649 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -63,11 +63,9 @@ Item { currentIndex: tabbar.currentIndex Item { - - ColumnLayout { - width: parent.width - y: 20 - spacing: 20 + Receive { + id: receive + anchors.fill: parent } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 6f9525345..0298edbcd 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -10,7 +10,7 @@ from .qeconfig import QEConfig from .qedaemon import QEDaemon, QEWalletListModel from .qenetwork import QENetwork from .qewallet import QEWallet -from .qeqr import QEQR +from .qeqr import QEQR, QEQRImageProvider from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin @@ -36,6 +36,9 @@ class ElectrumQmlApplication(QGuiApplication): self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') + self.qr_ip = QEQRImageProvider() + self.engine.addImageProvider('qrgen', self.qr_ip) + self.context = self.engine.rootContext() self._singletons['config'] = QEConfig(config) self._singletons['network'] = QENetwork(daemon.network) @@ -63,7 +66,7 @@ class ElectrumQmlApplication(QGuiApplication): def message_handler(self, line, funct, file): # filter out common harmless messages - if re.search('file:///.*TypeError:\ Cannot\ read\ property.*null$', file): + if re.search('file:///.*TypeError: Cannot read property.*null$', file): return self.logger.warning(file) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index cdbe914c4..d93323733 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -1,8 +1,15 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtGui import QImage +from PyQt5.QtQuick import QQuickImageProvider from electrum.logging import get_logger -#from PIL import Image +import qrcode +#from qrcode.image.styledpil import StyledPilImage +#from qrcode.image.styles.moduledrawers import * + +from PIL import Image, ImageQt + from ctypes import * class QEQR(QObject): @@ -11,22 +18,25 @@ class QEQR(QObject): self._text = text _logger = get_logger(__name__) - scan_ready_changed = pyqtSignal() - _ready = True + scanReadyChanged = pyqtSignal() + imageChanged = pyqtSignal() + + _scanReady = True + _image = None @pyqtSlot('QImage') def scanImage(self, image=None): - if not self._ready: + if not self._scanReady: self._logger.warning("Already processing an image. Check 'ready' property before calling scanImage") return - self._ready = False - self.scan_ready_changed.emit() + self._scanReady = False + self.scanReadyChanged.emit() pilimage = self.convertToPILImage(image) self.parseQR(pilimage) - self._ready = True + self._scanReady = True def logImageStats(self, image): self._logger.info('width: ' + str(image.width())) @@ -47,13 +57,32 @@ class QEQR(QObject): memmove(c_buf, c_void_p(rawimage.__int__()), numbytes) buf2 = bytes(buf) - return None #Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') + return Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') def parseQR(self, image): # TODO pass - @pyqtProperty(bool, notify=scan_ready_changed) - def ready(self): - return self._ready + @pyqtProperty(bool, notify=scanReadyChanged) + def scanReady(self): + return self._scanReady + + @pyqtProperty('QImage', notify=imageChanged) + def image(self): + return self._image + +class QEQRImageProvider(QQuickImageProvider): + def __init__(self, parent=None): + super().__init__(QQuickImageProvider.Image) + + _logger = get_logger(__name__) + + def requestImage(self, qstr, size): + self._logger.debug('QR requested for %s' % qstr) + qr = qrcode.QRCode(version=1, box_size=8, border=2) + qr.add_data(qstr) + qr.make(fit=True) + pimg = qr.make_image(fill_color='black', back_color='white') #image_factory=StyledPilImage, module_drawer=CircleModuleDrawer()) + qimg = ImageQt.ImageQt(pimg) + return qimg, qimg.size() From f5807df91c4031263d6015ab97880271973b8fe5 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 21 Mar 2022 18:56:48 +0100 Subject: [PATCH 056/218] add a container for styling constants, so we don't repeat literals all over the code --- electrum/gui/qml/components/Constants.qml | 16 ++++++++++++++++ electrum/gui/qml/components/main.qml | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 electrum/gui/qml/components/Constants.qml diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml new file mode 100644 index 000000000..3acad5be4 --- /dev/null +++ b/electrum/gui/qml/components/Constants.qml @@ -0,0 +1,16 @@ +import QtQuick 2.6 +import QtQuick.Controls.Material 2.0 + +QtObject { + readonly property int paddingSmall: 8 + readonly property int paddingMedium: 12 + readonly property int paddingLarge: 16 + readonly property int paddingXLarge: 20 + + readonly property int fontSizeXSmall: 10 + readonly property int fontSizeSmall: 12 + readonly property int fontSizeMedium: 15 + readonly property int fontSizeLarge: 18 + readonly property int fontSizeXLarge: 22 + readonly property int fontSizeXXLarge: 28 +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 2f9bdaa64..32db41366 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -19,6 +19,8 @@ ApplicationWindow Material.primary: Material.Indigo Material.accent: Material.LightBlue + property QtObject constants: Constants {} + property alias stack: mainStackView header: ToolBar { From e04dbe1eff3badbc6b8e2fe91acf43e8a140d099 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 21 Mar 2022 18:58:30 +0100 Subject: [PATCH 057/218] remove leftover, don't eagerly set wizard pages to not visible --- electrum/gui/qml/components/wizard/Wizard.qml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml index c87db3c98..0792349bb 100644 --- a/electrum/gui/qml/components/wizard/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -29,11 +29,7 @@ Dialog { pages.takeItem(pages.currentIndex+1).destroy() } - var page = comp.createObject(pages, { - 'visible': Qt.binding(function() { - return pages.currentItem === this - }) - }) + var page = comp.createObject(pages) page.validChanged.connect(function() { pages.pagevalid = page.valid } ) From cd6d2f8a692243b3ad174414ede00228e552d1e3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 21 Mar 2022 19:00:57 +0100 Subject: [PATCH 058/218] add QERequestListModel and hook up the gui in Receive tab --- electrum/gui/qml/components/Receive.qml | 186 +++++++++++++++++++++++- electrum/gui/qml/qerequestlistmodel.py | 76 ++++++++++ electrum/gui/qml/qewallet.py | 84 ++++++++++- 3 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 electrum/gui/qml/qerequestlistmodel.py 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) From dec0cdd0d3a58645a58e7b9463f9c4c1377be463 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 21 Mar 2022 19:28:39 +0100 Subject: [PATCH 059/218] refactor QEAddressListModel and QETransactionListModel to their own files --- electrum/gui/qml/qeaddresslistmodel.py | 72 ++++++++++++ electrum/gui/qml/qetransactionlistmodel.py | 56 ++++++++++ electrum/gui/qml/qewallet.py | 121 +-------------------- 3 files changed, 131 insertions(+), 118 deletions(-) create mode 100644 electrum/gui/qml/qeaddresslistmodel.py create mode 100644 electrum/gui/qml/qetransactionlistmodel.py diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py new file mode 100644 index 000000000..a9b60fd7b --- /dev/null +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -0,0 +1,72 @@ +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 + +class QEAddressListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.receive_addresses = [] + self.change_addresses = [] + + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('type','address','label','balance','numtx', 'held') + _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.receive_addresses) + len(self.change_addresses) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + if index.row() > len(self.receive_addresses) - 1: + address = self.change_addresses[index.row() - len(self.receive_addresses)] + else: + address = self.receive_addresses[index.row()] + role_index = role - (Qt.UserRole + 1) + value = address[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.receive_addresses = [] + self.change_addresses = [] + self.endResetModel() + + # initial model data + @pyqtSlot() + def init_model(self): + r_addresses = self.wallet.get_receiving_addresses() + c_addresses = self.wallet.get_change_addresses() + n_addresses = len(r_addresses) + len(c_addresses) + + def insert_row(atype, alist, address): + item = {} + item['type'] = atype + item['address'] = address + item['numtx'] = self.wallet.get_address_history_len(address) + item['label'] = self.wallet.get_label(address) + c, u, x = self.wallet.get_addr_balance(address) + item['balance'] = c + u + x + item['held'] = self.wallet.is_frozen_address(address) + alist.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, n_addresses - 1) + for address in r_addresses: + insert_row('receive', self.receive_addresses, address) + for address in c_addresses: + insert_row('change', self.change_addresses, address) + self.endInsertRows() + diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py new file mode 100644 index 000000000..e95bd588e --- /dev/null +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -0,0 +1,56 @@ +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 + +class QETransactionListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.tx_history = [] + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp','incoming','bc_value', + 'bc_balance','date','label','txpos_in_block','fee','inputs','outputs') + _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.tx_history) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + tx = self.tx_history[index.row()] + role_index = role - (Qt.UserRole + 1) + value = tx[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.tx_history = [] + self.endResetModel() + + # initial model data + def init_model(self): + history = self.wallet.get_detailed_history(show_addresses = True) + txs = history['transactions'] + # use primitives + for tx in txs: + for output in tx['outputs']: + output['value'] = output['value'].value + + self.clear() + self.beginInsertRows(QModelIndex(), 0, len(txs) - 1) + self.tx_history = txs + self.tx_history.reverse() + self.endInsertRows() + diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 214b4df03..7b7c7694f 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,5 +1,4 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex, QByteArray from typing import Optional, TYPE_CHECKING, Sequence, List, Union @@ -8,127 +7,13 @@ 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.transaction import PartialTxOutput 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): - super().__init__(parent) - self.wallet = wallet - self.tx_history = [] - - _logger = get_logger(__name__) - - # define listmodel rolemap - _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp','incoming','bc_value', - 'bc_balance','date','label','txpos_in_block','fee','inputs','outputs') - _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.tx_history) - - def roleNames(self): - return self._ROLE_MAP - - def data(self, index, role): - tx = self.tx_history[index.row()] - role_index = role - (Qt.UserRole + 1) - value = tx[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.tx_history = [] - self.endResetModel() - - # initial model data - def init_model(self): - history = self.wallet.get_detailed_history(show_addresses = True) - txs = history['transactions'] - # use primitives - for tx in txs: - for output in tx['outputs']: - output['value'] = output['value'].value - - self.clear() - self.beginInsertRows(QModelIndex(), 0, len(txs) - 1) - self.tx_history = txs - self.tx_history.reverse() - self.endInsertRows() - -class QEAddressListModel(QAbstractListModel): - def __init__(self, wallet, parent=None): - super().__init__(parent) - self.wallet = wallet - self.receive_addresses = [] - self.change_addresses = [] - - - _logger = get_logger(__name__) - - # define listmodel rolemap - _ROLE_NAMES=('type','address','label','balance','numtx', 'held') - _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.receive_addresses) + len(self.change_addresses) - - def roleNames(self): - return self._ROLE_MAP - - def data(self, index, role): - if index.row() > len(self.receive_addresses) - 1: - address = self.change_addresses[index.row() - len(self.receive_addresses)] - else: - address = self.receive_addresses[index.row()] - role_index = role - (Qt.UserRole + 1) - value = address[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.receive_addresses = [] - self.change_addresses = [] - self.endResetModel() - - # initial model data - @pyqtSlot() - def init_model(self): - r_addresses = self.wallet.get_receiving_addresses() - c_addresses = self.wallet.get_change_addresses() - n_addresses = len(r_addresses) + len(c_addresses) - - def insert_row(atype, alist, address): - item = {} - item['type'] = atype - item['address'] = address - item['numtx'] = self.wallet.get_address_history_len(address) - item['label'] = self.wallet.get_label(address) - c, u, x = self.wallet.get_addr_balance(address) - item['balance'] = c + u + x - item['held'] = self.wallet.is_frozen_address(address) - alist.append(item) - - self.clear() - self.beginInsertRows(QModelIndex(), 0, n_addresses - 1) - for address in r_addresses: - insert_row('receive', self.receive_addresses, address) - for address in c_addresses: - insert_row('change', self.change_addresses, address) - self.endInsertRows() +from .qetransactionlistmodel import QETransactionListModel +from .qeaddresslistmodel import QEAddressListModel class QEWallet(QObject): def __init__(self, wallet, parent=None): From 271f36d3b3dee4e5efd1d903bf97bf43f12d02c2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 22 Mar 2022 19:20:38 +0100 Subject: [PATCH 060/218] add yes/no button option to generic messagedialog, so it can be used to ask the user a simple yes/no question. --- electrum/gui/qml/components/main.qml | 72 +++++++++++++++++----------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 32db41366..c796791f2 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -145,6 +145,15 @@ ApplicationWindow Component { id: _messageDialog Dialog { + id: dialog + title: qsTr("Message") + + property bool yesno: false + property alias text: message.text + + signal yesClicked + signal noClicked + parent: Overlay.overlay modal: true x: (parent.width - width) / 2 @@ -153,13 +162,43 @@ ApplicationWindow color: "#aa000000" } - title: qsTr("Message") - property alias text: messageLabel.text - Label { - id: messageLabel - text: "Lorem ipsum dolor sit amet..." - } + ColumnLayout { + TextArea { + id: message + Layout.preferredWidth: Overlay.overlay.width *2/3 + readOnly: true + wrapMode: TextInput.WordWrap + //textFormat: TextEdit.RichText // existing translations not richtext yet + background: Rectangle { + color: 'transparent' + } + } + RowLayout { + Layout.alignment: Qt.AlignHCenter + Button { + text: qsTr('Ok') + visible: !yesno + onClicked: dialog.close() + } + Button { + text: qsTr('Yes') + visible: yesno + onClicked: { + yesClicked() + dialog.close() + } + } + Button { + text: qsTr('No') + visible: yesno + onClicked: { + noClicked() + dialog.close() + } + } + } + } } } @@ -184,33 +223,12 @@ ApplicationWindow app.header.visible = false mainStackView.clear() } -/* OpenWallet as a popup dialog attempt - Component { - id: _openWallet - Dialog { - parent: Overlay.overlay - modal: true - x: (parent.width - width) / 2 - y: (parent.height - height) / 2 - Overlay.modal: Rectangle { - color: "#aa000000" - } - - title: qsTr("OpenWallet") - OpenWallet { - path: Daemon.path - } - } - } -*/ Connections { target: Daemon function onWalletRequiresPassword() { console.log('wallet requires password') app.stack.push(Qt.resolvedUrl("OpenWallet.qml"), {"path": Daemon.path}) -// var dialog = _openWallet.createObject(app) - //dialog.open() } function onWalletOpenError(error) { console.log('wallet open error') From 03048d39b659d747980f22f3359b792ae3f15a88 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 22 Mar 2022 19:37:31 +0100 Subject: [PATCH 061/218] handle gap limit warning when creating Request. (using string error code for now, ideally should be properly defined as an enum) Also fix animation bug and work around broken ListView header implementation --- electrum/gui/qml/components/Receive.qml | 164 ++++++++++++++---------- electrum/gui/qml/qewallet.py | 33 ++--- 2 files changed, 113 insertions(+), 84 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 168b37702..5e80f3b7a 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -80,14 +80,12 @@ Pane { Layout.columnSpan: 2 text: qsTr('Create Request') onClicked: { - var a = parseFloat(amount.text) - Daemon.currentWallet.create_invoice(a, message.text, expires.currentValue) + createRequest() } } } Frame { - clip: true verticalPadding: 0 horizontalPadding: 0 @@ -103,18 +101,15 @@ Pane { color: Qt.darker(Material.background, 1.25) } - ListView { + ColumnLayout { + spacing: 0 anchors.fill: parent - model: Daemon.currentWallet.requestModel - headerPositioning: ListView.OverlayHeader - - header: Item { - z: 1 - height: hitem.height - width: ListView.view.width + Item { + Layout.preferredHeight: hitem.height + Layout.preferredWidth: parent.width Rectangle { - anchors.fill: hitem + anchors.fill: parent color: Qt.lighter(Material.background, 1.25) } RowLayout { @@ -127,76 +122,107 @@ Pane { } } - 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 + ListView { + Layout.fillHeight: true + Layout.fillWidth: true + clip: true + + model: Daemon.currentWallet.requestModel + + delegate: ItemDelegate { + id: root + height: item.height + width: ListView.view.width + + onClicked: console.log('Request ' + index + ' clicked') + + GridLayout { + id: item + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingSmall + rightMargin: constants.paddingSmall + } + + 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 } + NumberAnimation { properties: 'opacity'; to: 1.0; duration: 700 * (1-from) } + } } - - 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 } - } - } } + function createRequest(ignoreGaplimit = false) { + var a = parseFloat(amount.text) + Daemon.currentWallet.create_invoice(a, message.text, expires.currentValue, false, ignoreGaplimit) + } + Connections { target: Daemon.currentWallet function onRequestCreateSuccess() { message.text = '' amount.text = '' } - function onRequestCreateError(error) { - console.log(error) - var dialog = app.messageDialog.createObject(app, {'text': error}) + function onRequestCreateError(code, error) { + if (code == 'gaplimit') { + var dialog = app.messageDialog.createObject(app, {'text': error, 'yesno': true}) + dialog.yesClicked.connect(function() { + createRequest(true) + }) + } else { + console.log(error) + var dialog = app.messageDialog.createObject(app, {'text': error}) + } dialog.open() } } diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 7b7c7694f..eed5cef49 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -34,7 +34,7 @@ class QEWallet(QObject): dataChanged = pyqtSignal() # dummy to silence warnings requestCreateSuccess = pyqtSignal() - requestCreateError = pyqtSignal([str], arguments=['error']) + requestCreateError = pyqtSignal([str,str], arguments=['code','error']) requestStatus = pyqtSignal() def on_request_status(self, event, *args): @@ -122,12 +122,12 @@ class QEWallet(QObject): 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]: + def create_bitcoin_request(self, amount: int, message: str, expiration: int, ignore_gap: bool) -> Optional[str]: addr = self.wallet.get_unused_address() if addr is None: - # TODO implement - return - #if not self.wallet.is_deterministic(): # imported wallet + if not self.wallet.is_deterministic(): # imported wallet + # TODO implement + return #msg = [ #_('No more addresses in your wallet.'), ' ', #_('You are using a non-deterministic wallet, which cannot create new addresses.'), ' ', @@ -137,16 +137,18 @@ class QEWallet(QObject): #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) + else: # deterministic wallet + if not ignore_gap: + self.requestCreateError.emit('gaplimit',_("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)) + self.requestCreateError.emit('fatal',_('Error adding payment request') + ':\n' + repr(e)) else: # TODO: check this flow. Only if alias is defined in config. OpenAlias? pass @@ -155,24 +157,25 @@ class QEWallet(QObject): return addr @pyqtSlot(int, 'QString', int) - def create_invoice(self, amount: int, message: str, expiration: int, is_lightning: bool = False): + @pyqtSlot(int, 'QString', int, bool) + @pyqtSlot(int, 'QString', int, bool, bool) + def create_invoice(self, amount: int, message: str, expiration: int, is_lightning: bool = False, ignore_gap: 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.")) + self.requestCreateError.emit('fatal',_("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) + key = self.create_bitcoin_request(amount, message, expiry, ignore_gap) 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)) + self.requestCreateError.emit('fatal',_('Error creating payment request') + ':\n' + str(e)) return assert key is not None From cf059cb31bd85edca35e68ff08c3e9d5980ab6ff Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 23 Mar 2022 13:48:30 +0100 Subject: [PATCH 062/218] add initial fee histogram --- electrum/gui/qml/components/NetworkStats.qml | 87 +++++++++++++++++--- electrum/gui/qml/qenetwork.py | 11 +++ 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/electrum/gui/qml/components/NetworkStats.qml b/electrum/gui/qml/components/NetworkStats.qml index dff5f391d..eb60d1471 100644 --- a/electrum/gui/qml/components/NetworkStats.qml +++ b/electrum/gui/qml/components/NetworkStats.qml @@ -7,17 +7,80 @@ Pane { property string title: qsTr('Network') GridLayout { - columns: 2 - - Label { text: qsTr("Network: "); color: Material.primaryHighlightedTextColor; font.bold: true } - Label { text: Network.networkName } - Label { text: qsTr("Server: "); color: Material.primaryHighlightedTextColor; font.bold: true } - Label { text: Network.server } - Label { text: qsTr("Local Height: "); color: Material.primaryHighlightedTextColor; font.bold: true } - Label { text: Network.height } - Label { text: qsTr("Status: "); color: Material.primaryHighlightedTextColor; font.bold: true } - Label { text: Network.status } - Label { text: qsTr("Wallet: "); color: Material.primaryHighlightedTextColor; font.bold: true } - Label { text: Daemon.walletName } + columns: 3 + + Label { + text: qsTr("Network: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Label { + text: Network.networkName + Layout.columnSpan: 2 + } + + Label { + text: qsTr("Server: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Label { + text: Network.server + Layout.columnSpan: 2 + } + + Label { + text: qsTr("Local Height: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + + } + Label { + text: Network.height + Layout.columnSpan: 2 + } + + Label { + text: qsTr("Status: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Image { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + source: Daemon.currentWallet.isUptodate + ? "../../icons/status_connected.png" + : "../../icons/status_lagging.png" + } + Label { + text: Network.status + } + + Label { + text: qsTr("Network fees: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Label { + id: feeHistogram + Layout.columnSpan: 2 + } } + + function setFeeHistogram() { + var txt = '' + Network.feeHistogram.forEach(function(item) { + txt = txt + item[0] + ': ' + item[1] + '\n'; + }) + feeHistogram.text = txt.trim() + } + + Connections { + target: Network + function onFeeHistogramUpdated() { + setFeeHistogram() + } + } + + Component.onCompleted: setFeeHistogram() } diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index 11c696c1d..aa64e13aa 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -14,6 +14,7 @@ class QENetwork(QObject): register_callback(self.on_default_server_changed, ['default_server_changed']) register_callback(self.on_proxy_set, ['proxy_set']) register_callback(self.on_status, ['status']) + register_callback(self.on_fee_histogram, ['fee_histogram']) _logger = get_logger(__name__) @@ -23,6 +24,7 @@ class QENetwork(QObject): proxySet = pyqtSignal() proxyChanged = pyqtSignal() statusUpdated = pyqtSignal() + feeHistogramUpdated = pyqtSignal() dataChanged = pyqtSignal() # dummy to silence warnings @@ -54,6 +56,10 @@ class QENetwork(QObject): self._status = self.network.connection_status self.statusUpdated.emit() + def on_fee_histogram(self, event, *args): + self._logger.warning('fee histogram updated') + self.feeHistogramUpdated.emit() + @pyqtProperty(int,notify=networkUpdated) def updates(self): return self._num_updates @@ -102,3 +108,8 @@ class QENetwork(QObject): net_params = net_params._replace(proxy=proxy_settings) self.network.run_from_another_thread(self.network.set_parameters(net_params)) self.proxyChanged.emit() + + @pyqtProperty('QVariant',notify=feeHistogramUpdated) + def feeHistogram(self): + return self.network.get_status_value('fee_histogram') + From 426198dd426c54b4e867d6e8bc34257379326804 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 23 Mar 2022 13:51:46 +0100 Subject: [PATCH 063/218] add base unit setting and conversion methods in qeconfig.py --- electrum/gui/qml/qeconfig.py | 60 ++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index d65e22819..14e9f9ba2 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -1,5 +1,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from decimal import Decimal + from electrum.logging import get_logger class QEConfig(QObject): @@ -10,9 +12,6 @@ class QEConfig(QObject): _logger = get_logger(__name__) autoConnectChanged = pyqtSignal() - serverStringChanged = pyqtSignal() - manualServerChanged = pyqtSignal() - @pyqtProperty(bool, notify=autoConnectChanged) def autoConnect(self): return self.config.get('auto_connect') @@ -27,6 +26,7 @@ class QEConfig(QObject): def autoConnectDefined(self): return self.config.get('auto_connect') is not None + serverStringChanged = pyqtSignal() @pyqtProperty('QString', notify=serverStringChanged) def serverString(self): return self.config.get('server') @@ -36,6 +36,7 @@ class QEConfig(QObject): self.config.set_key('server', server, True) self.serverStringChanged.emit() + manualServerChanged = pyqtSignal() @pyqtProperty(bool, notify=manualServerChanged) def manualServer(self): return self.config.get('oneserver') @@ -45,3 +46,56 @@ class QEConfig(QObject): self.config.set_key('oneserver', oneserver, True) self.manualServerChanged.emit() + baseUnitChanged = pyqtSignal() + @pyqtProperty(str, notify=baseUnitChanged) + def baseUnit(self): + return self.config.get_base_unit() + + @baseUnit.setter + def baseUnit(self, unit): + self.config.set_base_unit(unit) + self.baseUnitChanged.emit() + + thousandsSeparatorChanged = pyqtSignal() + @pyqtProperty(bool, notify=thousandsSeparatorChanged) + def thousandsSeparator(self): + return self.config.get('amt_add_thousands_sep', False) + + @thousandsSeparator.setter + def thousandsSeparator(self, checked): + self.config.set_key('amt_add_thousands_sep', checked) + self.config.amt_add_thousands_sep = checked + self.thousandsSeparatorChanged.emit() + + + @pyqtSlot(int, result=str) + @pyqtSlot(int, bool, result=str) + def formatSats(self, satoshis, with_unit=False): + if with_unit: + return self.config.format_amount_and_units(satoshis) + else: + return self.config.format_amount(satoshis) + + # TODO delegate all this to config.py/util.py + def decimal_point(self): + return self.config.get('decimal_point') + + def max_precision(self): + return self.decimal_point() + 0 #self.extra_precision + + @pyqtSlot(str, result=int) + def unitsToSats(self, unitAmount): + # returns amt in satoshis + try: + x = Decimal(unitAmount) + except: + return None + # scale it to max allowed precision, make it an int + max_prec_amount = int(pow(10, self.max_precision()) * x) + # if the max precision is simply what unit conversion allows, just return + if self.max_precision() == self.decimal_point(): + return max_prec_amount + # otherwise, scale it back to the expected unit + #amount = Decimal(max_prec_amount) / Decimal(pow(10, self.max_precision()-self.decimal_point())) + #return int(amount) #Decimal(amount) if not self.is_int else int(amount) + From 3d0fbe5f21e050741615869f1c807593b5be7bae Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 23 Mar 2022 13:54:26 +0100 Subject: [PATCH 064/218] add initial Preferences page --- electrum/gui/qml/components/Preferences.qml | 83 +++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 electrum/gui/qml/components/Preferences.qml diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml new file mode 100644 index 000000000..98f560302 --- /dev/null +++ b/electrum/gui/qml/components/Preferences.qml @@ -0,0 +1,83 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +Pane { + property string title: qsTr("Preferences") + + ColumnLayout { + anchors.fill: parent + + Flickable { + Layout.fillHeight: true + Layout.fillWidth: true + + GridLayout { + id: rootLayout + columns: 2 + + Label { + text: qsTr('Language') + } + + ComboBox { + id: language + enabled: false + } + + Label { + text: qsTr('Base unit') + } + + ComboBox { + id: baseUnit + model: ['BTC','mBTC','bits','sat'] + } + + CheckBox { + id: thousands + Layout.columnSpan: 2 + text: qsTr('Add thousands separators to bitcoin amounts') + } + + CheckBox { + id: checkSoftware + Layout.columnSpan: 2 + text: qsTr('Automatically check for software updates') + enabled: false + } + + CheckBox { + id: writeLogs + Layout.columnSpan: 2 + text: qsTr('Write logs to file') + enabled: false + } + } + + } + + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + Button { + text: qsTr('Save') + onClicked: save() + } + } + } + + function save() { + Config.baseUnit = baseUnit.currentValue + Config.thousandsSeparator = thousands.checked + app.stack.pop() + } + + Component.onCompleted: { + baseUnit.currentIndex = ['BTC','mBTC','bits','sat'].indexOf(Config.baseUnit) + thousands.checked = Config.thousandsSeparator + } +} From 695f7a31cd56a45e400835eaf108c4ddc672d32e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 23 Mar 2022 13:57:20 +0100 Subject: [PATCH 065/218] add padding constants --- electrum/gui/qml/components/Constants.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index 3acad5be4..b83d0ad94 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -2,10 +2,12 @@ import QtQuick 2.6 import QtQuick.Controls.Material 2.0 QtObject { + readonly property int paddingTiny: 4 readonly property int paddingSmall: 8 readonly property int paddingMedium: 12 readonly property int paddingLarge: 16 readonly property int paddingXLarge: 20 + readonly property int paddingXXLarge: 28 readonly property int fontSizeXSmall: 10 readonly property int fontSizeSmall: 12 From 5cfa1fd772409aabda1d34d45edacbfad2f5b7ff Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 23 Mar 2022 13:59:46 +0100 Subject: [PATCH 066/218] add Preferences to menu and add icons --- .../gui/qml/components/WalletMainView.qml | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index a096c1649..f899e0567 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -9,9 +9,45 @@ Item { property string title: Daemon.walletName property QtObject menu: Menu { - MenuItem { text: qsTr('Addresses'); onTriggered: stack.push(Qt.resolvedUrl('Addresses.qml')); visible: Daemon.currentWallet != null } - MenuItem { text: qsTr('Wallets'); onTriggered: stack.push(Qt.resolvedUrl('Wallets.qml')) } - MenuItem { text: qsTr('Network'); onTriggered: stack.push(Qt.resolvedUrl('NetworkStats.qml')) } + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Addresses'); + onTriggered: menu.openPage(Qt.resolvedUrl('Addresses.qml')); + enabled: Daemon.currentWallet != null + icon.source: '../../icons/tab_addresses.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Wallets'); + onTriggered: menu.openPage(Qt.resolvedUrl('Wallets.qml')) + icon.source: '../../icons/wallet.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Network'); + onTriggered: menu.openPage(Qt.resolvedUrl('NetworkStats.qml')) + icon.source: '../../icons/network.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Preferences'); + onTriggered: menu.openPage(Qt.resolvedUrl('Preferences.qml')) + icon.source: '../../icons/preferences.png' + } + } + + function openPage(url) { + stack.push(url) + currentIndex = -1 + } } ColumnLayout { @@ -53,6 +89,7 @@ Item { enabled: !Daemon.currentWallet.isWatchOnly text: qsTr('Send') } + Component.onCompleted: tabbar.setCurrentIndex(1) } SwipeView { From a75960a70d1f105e7a29a0a65c38b051fa525029 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 23 Mar 2022 14:00:46 +0100 Subject: [PATCH 067/218] use configured units everywhere --- .../gui/qml/components/BalanceSummary.qml | 41 ++++- electrum/gui/qml/components/History.qml | 16 +- electrum/gui/qml/components/Receive.qml | 161 ++++++++++++++---- electrum/gui/qml/components/Send.qml | 20 ++- 4 files changed, 192 insertions(+), 46 deletions(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index 9c452f75b..c50db3d87 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -2,30 +2,55 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 -Item { +Frame { + id: root height: layout.height + property string formattedBalance + property string formattedUnconfirmed + + function setBalances() { + root.formattedBalance = Config.formatSats(Daemon.currentWallet.confirmedBalance, true) + root.formattedUnconfirmed = Config.formatSats(Daemon.currentWallet.unconfirmedBalance, true) + } + GridLayout { id: layout columns: 3 Label { + id: balance Layout.columnSpan: 3 - font.pointSize: 14 - text: 'Balance: ' + Daemon.currentWallet.confirmedBalance //'5.6201 mBTC' + font.pixelSize: constants.fontSizeLarge + text: 'Balance: ' + formattedBalance } Label { - font.pointSize: 8 - text: 'Confirmed: ' + Daemon.currentWallet.confirmedBalance + id: confirmed + font.pixelSize: constants.fontSizeMedium + text: 'Confirmed: ' + formattedBalance } Label { - font.pointSize: 8 - text: 'Unconfirmed: ' + Daemon.currentWallet.unconfirmedBalance + id: unconfirmed + font.pixelSize: constants.fontSizeMedium + text: 'Unconfirmed: ' + formattedUnconfirmed } Label { - font.pointSize: 8 + id: lightning + font.pixelSize: constants.fontSizeSmall text: 'Lightning: ?' } } + Connections { + target: Config + function onBaseUnitChanged() { setBalances() } + function onThousandsSeparatorChanged() { setBalances() } + } + + Connections { + target: Daemon + function onWalletLoaded() { setBalances() } + } + + Component.onCompleted: setBalances() } diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 0289d81eb..538190325 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -59,8 +59,9 @@ Pane { color: model.label !== '' ? Material.accentColor : 'gray' } Label { + id: valueLabel font.pixelSize: 15 - text: model.bc_value + text: Config.formatSats(model.bc_value) font.bold: true color: model.incoming ? "#ff80ff80" : "#ffff8080" } @@ -112,6 +113,19 @@ Pane { } } + + // as the items in the model are not bindings to QObjects, + // hook up events that might change the appearance + Connections { + target: Config + function onBaseUnitChanged() { + valueLabel.text = Config.formatSats(model.bc_value) + } + function onThousandsSeparatorChanged() { + valueLabel.text = Config.formatSats(model.bc_value) + } + } + } // delegate } diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 5e80f3b7a..4f5fc2d5c 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -14,7 +14,7 @@ Pane { width: parent.width rowSpacing: 10 columnSpacing: 10 - columns: 3 + columns: 4 Label { text: qsTr('Message') @@ -22,8 +22,7 @@ Pane { TextField { id: message - onTextChanged: img.source = 'image://qrgen/' + text - Layout.columnSpan: 2 + Layout.columnSpan: 3 Layout.fillWidth: true } @@ -31,53 +30,113 @@ Pane { text: qsTr('Requested Amount') wrapMode: Text.WordWrap Layout.preferredWidth: 50 // trigger wordwrap + Layout.rightMargin: constants.paddingXLarge + Layout.rowSpan: 2 } TextField { id: amount + Layout.fillWidth: true } - Item { - Layout.rowSpan: 3 - width: img.width - height: img.height - - Image { - id: img - cache: false - anchors { - top: parent.top - left: parent.left + Label { + text: Config.baseUnit + color: Material.accentColor + } + + ColumnLayout { + Layout.rowSpan: 2 + Layout.preferredWidth: rootItem.width /3 + Layout.leftMargin: constants.paddingXLarge + + Label { + text: qsTr('Expires after') + Layout.fillWidth: false + } + + ComboBox { + id: expires + Layout.fillWidth: true + 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('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}) + expiresmodel.append({'text': qsTr('1 month'), 'value': 31*7*24*60*60}) + expiresmodel.append({'text': qsTr('Never'), 'value': 0}) + expires.currentIndex = 0 + } } - source: 'image://qrgen/test' } } + TextField { + id: amountFiat + Layout.fillWidth: true + } + Label { - text: qsTr('Expires after') - Layout.fillWidth: false + text: qsTr('EUR') + color: Material.accentColor } - 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 + RowLayout { + Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter + CheckBox { + id: cb_onchain + text: qsTr('Onchain') + checked: true + contentItem: RowLayout { + Text { + text: cb_onchain.text + font: cb_onchain.font + opacity: enabled ? 1.0 : 0.3 + color: Material.foreground + verticalAlignment: Text.AlignVCenter + leftPadding: cb_onchain.indicator.width + cb_onchain.spacing + } + Image { + x: 16 + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + source: '../../icons/bitcoin.png' + } + } + } + + CheckBox { + id: cb_lightning + text: qsTr('Lightning') + enabled: false + contentItem: RowLayout { + Text { + text: cb_lightning.text + font: cb_lightning.font + opacity: enabled ? 1.0 : 0.3 + color: Material.foreground + verticalAlignment: Text.AlignVCenter + leftPadding: cb_lightning.indicator.width + cb_lightning.spacing + } + Image { + x: 16 + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + source: '../../icons/lightning.png' + } } } } Button { - Layout.columnSpan: 2 + Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter text: qsTr('Create Request') onClicked: { createRequest() @@ -148,6 +207,12 @@ Pane { columns: 5 + Rectangle { + Layout.columnSpan: 5 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } Image { Layout.rowSpan: 2 Layout.preferredWidth: 32 @@ -166,7 +231,8 @@ Pane { font.pixelSize: constants.fontSizeSmall } Label { - text: model.amount + id: amount + text: Config.formatSats(model.amount, true) font.pixelSize: constants.fontSizeSmall } @@ -187,7 +253,24 @@ Pane { text: model.status font.pixelSize: constants.fontSizeSmall } + Rectangle { + Layout.columnSpan: 5 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = Config.formatSats(model.amount, true) + } + function onThousandsSeparatorChanged() { + amount.text = Config.formatSats(model.amount, true) + } + } + } add: Transition { @@ -198,12 +281,20 @@ Pane { NumberAnimation { properties: 'y'; duration: 100 } NumberAnimation { properties: 'opacity'; to: 1.0; duration: 700 * (1-from) } } + + ScrollBar.vertical: ScrollBar { + parent: parent.parent + anchors.top: parent.top + anchors.left: parent.right + anchors.bottom: parent.bottom + } + } } } function createRequest(ignoreGaplimit = false) { - var a = parseFloat(amount.text) + var a = Config.unitsToSats(amount.text) Daemon.currentWallet.create_invoice(a, message.text, expires.currentValue, false, ignoreGaplimit) } @@ -212,6 +303,8 @@ Pane { function onRequestCreateSuccess() { message.text = '' amount.text = '' +// var dialog = app.showAsQrDialog.createObject(app, {'text': 'test'}) +// dialog.open() } function onRequestCreateError(code, error) { if (code == 'gaplimit') { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index e7f033031..fa71d1f4a 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -1,6 +1,7 @@ import QtQuick 2.6 import QtQuick.Controls 2.0 import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 Pane { id: rootItem @@ -11,7 +12,7 @@ Pane { BalanceSummary { Layout.columnSpan: 4 - //Layout.alignment: Qt.AlignHCenter + Layout.alignment: Qt.AlignHCenter } Label { @@ -20,11 +21,18 @@ Pane { TextField { id: address - Layout.columnSpan: 3 + Layout.columnSpan: 2 placeholderText: 'Paste address or invoice' Layout.fillWidth: true } + ToolButton { + icon.source: '../../icons/copy.png' + icon.color: 'transparent' + icon.height: 16 + icon.width: 16 + } + Label { text: "Amount" } @@ -34,6 +42,12 @@ Pane { placeholderText: 'Amount' } + Label { + text: Config.baseUnit + color: Material.accentColor + Layout.columnSpan: 2 + } + Label { text: "Fee" } @@ -41,6 +55,7 @@ Pane { TextField { id: fee placeholderText: 'sat/vB' + Layout.columnSpan: 2 } Item { @@ -51,7 +66,6 @@ Pane { spacing: 10 anchors.horizontalCenter: parent.horizontalCenter Button { -// anchors.horizontalCenter: parent.horizontalCenter text: 'Pay' enabled: address.text != '' && amount.text != '' && fee.text != '' // TODO proper validation onClicked: { From 7b71323506bd34e305b89794238dc334ff6c581d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 24 Mar 2022 20:47:17 +0100 Subject: [PATCH 068/218] cleanup --- electrum/gui/qml/qeconfig.py | 8 +++++--- electrum/gui/qml/qenetwork.py | 10 +++++----- electrum/gui/qml/qetransactionlistmodel.py | 5 +++-- electrum/gui/qml/qewallet.py | 1 - 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 14e9f9ba2..ebf736e1e 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -3,6 +3,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from decimal import Decimal from electrum.logging import get_logger +from electrum.util import DECIMAL_POINT_DEFAULT class QEConfig(QObject): def __init__(self, config, parent=None): @@ -78,7 +79,7 @@ class QEConfig(QObject): # TODO delegate all this to config.py/util.py def decimal_point(self): - return self.config.get('decimal_point') + return self.config.get('decimal_point', DECIMAL_POINT_DEFAULT) def max_precision(self): return self.decimal_point() + 0 #self.extra_precision @@ -89,13 +90,14 @@ class QEConfig(QObject): try: x = Decimal(unitAmount) except: - return None + return 0 # scale it to max allowed precision, make it an int max_prec_amount = int(pow(10, self.max_precision()) * x) # if the max precision is simply what unit conversion allows, just return if self.max_precision() == self.decimal_point(): return max_prec_amount + self._logger.debug('fallthrough') # otherwise, scale it back to the expected unit #amount = Decimal(max_prec_amount) / Decimal(pow(10, self.max_precision()-self.decimal_point())) #return int(amount) #Decimal(amount) if not self.is_int else int(amount) - + return 0 diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index aa64e13aa..0f9ea33bc 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -23,7 +23,7 @@ class QENetwork(QObject): defaultServerChanged = pyqtSignal() proxySet = pyqtSignal() proxyChanged = pyqtSignal() - statusUpdated = pyqtSignal() + statusChanged = pyqtSignal() feeHistogramUpdated = pyqtSignal() dataChanged = pyqtSignal() # dummy to silence warnings @@ -52,12 +52,12 @@ class QENetwork(QObject): self.proxySet.emit() def on_status(self, event, *args): - self._logger.info('status updated') + self._logger.debug('status updated: %s' % self.network.connection_status) self._status = self.network.connection_status - self.statusUpdated.emit() + self.statusChanged.emit() def on_fee_histogram(self, event, *args): - self._logger.warning('fee histogram updated') + self._logger.debug('fee histogram updated') self.feeHistogramUpdated.emit() @pyqtProperty(int,notify=networkUpdated) @@ -83,7 +83,7 @@ class QENetwork(QObject): net_params = net_params._replace(server=server) self.network.run_from_another_thread(self.network.set_parameters(net_params)) - @pyqtProperty('QString',notify=statusUpdated) + @pyqtProperty('QString',notify=statusChanged) def status(self): return self._status diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index e95bd588e..88a44627d 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -13,8 +13,9 @@ class QETransactionListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp','incoming','bc_value', - 'bc_balance','date','label','txpos_in_block','fee','inputs','outputs') + _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp', + 'incoming','bc_value','bc_balance','date','label','txpos_in_block','fee', + 'inputs','outputs') _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])) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index eed5cef49..7e0a6b40e 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -172,7 +172,6 @@ class QEWallet(QObject): key = self.create_bitcoin_request(amount, message, expiry, ignore_gap) if not key: return - #self.address_list.update() self._addressModel.init_model() except InvoiceError as e: self.requestCreateError.emit('fatal',_('Error creating payment request') + ':\n' + str(e)) From 7e6991c09733e55ba921d25d946c4e38b875f168 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 24 Mar 2022 21:10:01 +0100 Subject: [PATCH 069/218] UI --- .../gui/qml/components/BalanceSummary.qml | 29 +++--- electrum/gui/qml/components/Constants.qml | 4 + electrum/gui/qml/components/NetworkStats.qml | 12 +-- electrum/gui/qml/components/Receive.qml | 23 ++--- electrum/gui/qml/components/Send.qml | 65 ++++++++------ electrum/gui/qml/components/Wallets.qml | 90 ++++++++++--------- electrum/gui/qml/components/main.qml | 48 ++++++---- electrum/gui/qml/components/wizard/Wizard.qml | 9 ++ 8 files changed, 165 insertions(+), 115 deletions(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index c50db3d87..f5170dabe 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -1,6 +1,7 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 Frame { id: root @@ -17,27 +18,33 @@ Frame { GridLayout { id: layout - columns: 3 + columns: 2 Label { - id: balance - Layout.columnSpan: 3 font.pixelSize: constants.fontSizeLarge - text: 'Balance: ' + formattedBalance + text: qsTr('Balance: ') + } + Label { + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + text: formattedBalance } Label { - id: confirmed font.pixelSize: constants.fontSizeMedium - text: 'Confirmed: ' + formattedBalance + text: qsTr('Confirmed: ') } Label { - id: unconfirmed font.pixelSize: constants.fontSizeMedium - text: 'Unconfirmed: ' + formattedUnconfirmed + color: Material.accentColor + text: formattedBalance } Label { - id: lightning - font.pixelSize: constants.fontSizeSmall - text: 'Lightning: ?' + font.pixelSize: constants.fontSizeMedium + text: qsTr('Unconfirmed: ') + } + Label { + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + text: formattedUnconfirmed } } diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index b83d0ad94..1ea8452cb 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -15,4 +15,8 @@ QtObject { readonly property int fontSizeLarge: 18 readonly property int fontSizeXLarge: 22 readonly property int fontSizeXXLarge: 28 + + readonly property int iconSizeSmall: 16 + readonly property int iconSizeMedium: 24 + readonly property int iconSizeLarge: 32 } diff --git a/electrum/gui/qml/components/NetworkStats.qml b/electrum/gui/qml/components/NetworkStats.qml index eb60d1471..f23e2c4ca 100644 --- a/electrum/gui/qml/components/NetworkStats.qml +++ b/electrum/gui/qml/components/NetworkStats.qml @@ -46,11 +46,13 @@ Pane { font.bold: true } Image { - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - source: Daemon.currentWallet.isUptodate - ? "../../icons/status_connected.png" - : "../../icons/status_lagging.png" + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + source: Network.status == 'connecting' || Network.status == 'disconnected' + ? '../../icons/status_disconnected.png' : + Daemon.currentWallet.isUptodate + ? '../../icons/status_connected.png' + : '../../icons/status_lagging.png' } Label { text: Network.status diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 4f5fc2d5c..ecf50dfbc 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -156,9 +156,7 @@ Pane { bottom: parent.bottom } - background: Rectangle { - color: Qt.darker(Material.background, 1.25) - } + background: PaneInsetBackground {} ColumnLayout { spacing: 0 @@ -223,6 +221,7 @@ Pane { Layout.fillWidth: true Layout.columnSpan: 2 text: model.message + elide: Text.ElideRight font.pixelSize: constants.fontSizeLarge } @@ -233,6 +232,7 @@ Pane { Label { id: amount text: Config.formatSats(model.amount, true) + font.family: FixedFont font.pixelSize: constants.fontSizeSmall } @@ -282,17 +282,20 @@ Pane { NumberAnimation { properties: 'opacity'; to: 1.0; duration: 700 * (1-from) } } - ScrollBar.vertical: ScrollBar { - parent: parent.parent - anchors.top: parent.top - anchors.left: parent.right - anchors.bottom: parent.bottom - } - + ScrollIndicator.vertical: ScrollIndicator { } } } } + // make clicking the dialog background move the scope away from textedit fields + // so the keyboard goes away + MouseArea { + anchors.fill: parent + z: -1000 + onClicked: parkFocus.focus = true + FocusScope { id: parkFocus } + } + function createRequest(ignoreGaplimit = false) { var a = Config.unitsToSats(amount.text) Daemon.currentWallet.create_invoice(a, message.text, expires.currentValue, false, ignoreGaplimit) diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index fa71d1f4a..81b3d1362 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -16,14 +16,14 @@ Pane { } Label { - text: "Recipient" + text: qsTr('Recipient') } TextField { id: address Layout.columnSpan: 2 - placeholderText: 'Paste address or invoice' Layout.fillWidth: true + placeholderText: qsTr('Paste address or invoice') } ToolButton { @@ -34,57 +34,64 @@ Pane { } Label { - text: "Amount" + text: qsTr('Amount') } TextField { id: amount - placeholderText: 'Amount' + placeholderText: qsTr('Amount') } Label { text: Config.baseUnit color: Material.accentColor - Layout.columnSpan: 2 + Layout.fillWidth: true } + Item { width: 1; height: 1 } // workaround colspan on baseunit messing up row above + Label { - text: "Fee" + text: qsTr('Fee') } TextField { id: fee - placeholderText: 'sat/vB' - Layout.columnSpan: 2 + placeholderText: qsTr('sat/vB') + Layout.columnSpan: 3 } - Item { - Layout.fillWidth: true + RowLayout { Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter + spacing: 10 - Row { - spacing: 10 - anchors.horizontalCenter: parent.horizontalCenter - Button { - text: 'Pay' - enabled: address.text != '' && amount.text != '' && fee.text != '' // TODO proper validation - onClicked: { - var i_amount = parseInt(amount.text) - if (isNaN(i_amount)) - return - var result = Daemon.currentWallet.send_onchain(address.text, i_amount, undefined, false) - if (result) - app.stack.pop() - } + Button { + text: qsTr('Pay') + enabled: false // TODO proper validation + onClicked: { + var i_amount = parseInt(amount.text) + if (isNaN(i_amount)) + return + var result = Daemon.currentWallet.send_onchain(address.text, i_amount, undefined, false) + if (result) + app.stack.pop() } + } - Button { - text: 'Scan QR Code' - Layout.alignment: Qt.AlignHCenter - onClicked: app.stack.push(Qt.resolvedUrl('Scan.qml')) - } + Button { + text: qsTr('Scan QR Code') + onClicked: app.stack.push(Qt.resolvedUrl('Scan.qml')) } } } + // make clicking the dialog background move the scope away from textedit fields + // so the keyboard goes away + MouseArea { + anchors.fill: parent + z: -1000 + onClicked: parkFocus.focus = true + FocusScope { id: parkFocus } + } + } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index f33f29d6a..337dd8848 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -49,59 +49,61 @@ Pane { } // } - Item { - width: parent.width -// height: detailsFrame.height - Layout.fillHeight: true Frame { id: detailsFrame - width: parent.width - height: parent.height - - ListView { - id: listview - width: parent.width -// Layout.fillHeight: true - height: parent.height - clip: true - model: Daemon.availableWallets - - // header: sadly seems to be buggy - - delegate: AbstractButton { - width: ListView.view.width - height: 50 - onClicked: { - wallet_db.path = model.path - } - - RowLayout { - spacing: 10 - width: parent.width - - Image { - id: walleticon - source: "../../icons/wallet.png" - fillMode: Image.PreserveAspectFit - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 + Layout.preferredWidth: parent.width + Layout.fillHeight: true + verticalPadding: 0 + horizontalPadding: 0 + background: PaneInsetBackground {} + + ListView { + id: listview + width: parent.width + height: parent.height + clip: true + model: Daemon.availableWallets + + delegate: AbstractButton { + width: ListView.view.width + height: row.height + onClicked: { + wallet_db.path = model.path } - Label { - font.pixelSize: 18 - text: model.name - Layout.fillWidth: true - } + RowLayout { + id: row + spacing: 10 + x: constants.paddingSmall + width: parent.width - 2 * constants.paddingSmall + + Image { + id: walleticon + source: "../../icons/wallet.png" + fillMode: Image.PreserveAspectFit + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + } - Button { - text: 'Open' - onClicked: { - Daemon.load_wallet(model.path) + Label { + font.pixelSize: 18 + text: model.name + Layout.fillWidth: true + } + + Button { + text: 'Open' + onClicked: { + Daemon.load_wallet(model.path) + } } } } + + ScrollIndicator.vertical: ScrollIndicator { } } - }}} + + } Button { Layout.alignment: Qt.AlignHCenter diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index c796791f2..4ec96db83 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -27,11 +27,23 @@ ApplicationWindow id: toolbar RowLayout { anchors.fill: parent + ToolButton { text: qsTr("‹") enabled: stack.depth > 1 onClicked: stack.pop() } + + Label { + text: stack.currentItem.title + elide: Label.ElideRight + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + Layout.fillWidth: true + font.pixelSize: constants.fontSizeMedium + font.bold: true + } + Item { visible: Network.isTestNet width: column.width @@ -47,12 +59,13 @@ ApplicationWindow } - Column { + ColumnLayout { id: column + spacing: 0 Image { - anchors.horizontalCenter: parent.horizontalCenter - width: 16 - height: 16 + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall source: "../../icons/info.png" } @@ -60,28 +73,31 @@ ApplicationWindow id: networkNameLabel text: Network.networkName color: Material.accentColor - font.pointSize: 5 + font.pixelSize: constants.fontSizeXSmall } } } Image { - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - source: Daemon.currentWallet.isUptodate ? "../../icons/status_connected.png" : "../../icons/status_lagging.png" + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + source: Network.status == 'connecting' || Network.status == 'disconnected' + ? '../../icons/status_disconnected.png' : + Daemon.currentWallet.isUptodate + ? '../../icons/status_connected.png' + : '../../icons/status_lagging.png' } - Label { - text: stack.currentItem.title - elide: Label.ElideRight - horizontalAlignment: Qt.AlignHCenter - verticalAlignment: Qt.AlignVCenter - Layout.fillWidth: true - font.pixelSize: 14 - font.bold: true + Rectangle { + color: 'transparent' + Layout.preferredWidth: constants.paddingSmall + height: 1 + visible: !menuButton.visible } ToolButton { + id: menuButton + visible: stack.currentItem.menu !== undefined && stack.currentItem.menu.count > 0 text: qsTr("⋮") onClicked: { stack.currentItem.menu.open() diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml index 0792349bb..b89d78291 100644 --- a/electrum/gui/qml/components/wizard/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -162,5 +162,14 @@ Dialog { } } + // make clicking the dialog background move the scope away from textedit fields + // so the keyboard goes away + // TODO: here it works on desktop, but not android. hmm. + MouseArea { + anchors.fill: wizard + z: -1000 + onClicked: { parkFocus.focus = true } + FocusScope { id: parkFocus } + } } From 88e8993442ba2653c45b44a5c7c1f984af1660a2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 24 Mar 2022 21:19:29 +0100 Subject: [PATCH 070/218] Add PT Mono monospace font. --- electrum/gui/qml/components/Addresses.qml | 62 ++++++++---- electrum/gui/qml/components/History.qml | 3 + electrum/gui/qml/fonts/PTMono-Regular.ttf | Bin 0 -> 169480 bytes .../gui/qml/fonts/PTMono-Regular.ttf.LICENSE | 94 ++++++++++++++++++ electrum/gui/qml/qeapp.py | 10 +- 5 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 electrum/gui/qml/fonts/PTMono-Regular.ttf create mode 100644 electrum/gui/qml/fonts/PTMono-Regular.ttf.LICENSE diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index c2b57a79e..bfa45fb26 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -7,7 +7,6 @@ import org.electrum 1.0 Pane { id: rootItem - anchors.fill: parent property string title: Daemon.walletName + ' - ' + qsTr('Addresses') @@ -27,10 +26,14 @@ Pane { clip: true model: Daemon.currentWallet.addressModel + section.property: 'type' + section.criteria: ViewSection.FullString + section.delegate: sectionDelegate + delegate: AbstractButton { id: delegate width: ListView.view.width - height: 30 + height: delegateLayout.height background: Rectangle { color: model.held ? Qt.rgba(1,0,0,0.5) : @@ -45,44 +48,69 @@ Pane { visible: model.index > 0 } } + RowLayout { - x: 10 - spacing: 5 - width: parent.width - 20 - anchors.verticalCenter: parent.verticalCenter + id: delegateLayout + x: constants.paddingSmall + spacing: constants.paddingSmall + width: parent.width - 2*constants.paddingSmall Label { - font.pixelSize: 12 - text: model.type - } - Label { - font.pixelSize: 12 - font.family: "Courier" // TODO: use system monospace font + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont text: model.address elide: Text.ElideMiddle - Layout.maximumWidth: delegate.width / 4 + Layout.maximumWidth: delegate.width / 3 } Label { - font.pixelSize: 12 + font.pixelSize: constants.fontSizeMedium text: model.label elide: Text.ElideRight - Layout.minimumWidth: delegate.width / 4 + Layout.minimumWidth: delegate.width / 3 Layout.fillWidth: true } Label { - font.pixelSize: 12 + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont text: model.balance } Label { - font.pixelSize: 12 + font.pixelSize: constants.fontSizeMedium text: model.numtx } } } + + ScrollIndicator.vertical: ScrollIndicator { } } } } + Component { + id: sectionDelegate + Rectangle { + id: root + width: ListView.view.width + height: childrenRect.height + color: 'transparent' + + required property string section + + GridLayout { + Label { + topPadding: constants.paddingMedium + bottomPadding: constants.paddingMedium + text: root.section + ' ' + qsTr('addresses') + font.bold: true + font.pixelSize: constants.fontSizeLarge + } + ToolButton { + + } + } + } + } + Component.onCompleted: Daemon.currentWallet.addressModel.init_model() } diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 538190325..8239a0275 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -60,6 +60,7 @@ Pane { } Label { id: valueLabel + font.family: FixedFont font.pixelSize: 15 text: Config.formatSats(model.bc_value) font.bold: true @@ -128,6 +129,8 @@ Pane { } // delegate + ScrollIndicator.vertical: ScrollIndicator { } + } } diff --git a/electrum/gui/qml/fonts/PTMono-Regular.ttf b/electrum/gui/qml/fonts/PTMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b1983838c6622e4e087f02bcb46ec53ec22afd90 GIT binary patch literal 169480 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 XaW%?U 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 Date: Thu, 24 Mar 2022 22:00:49 +0100 Subject: [PATCH 071/218] add PaneInsetBackground --- .../qml/components/PaneInsetBackground.qml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 electrum/gui/qml/components/PaneInsetBackground.qml diff --git a/electrum/gui/qml/components/PaneInsetBackground.qml b/electrum/gui/qml/components/PaneInsetBackground.qml new file mode 100644 index 000000000..f9719969a --- /dev/null +++ b/electrum/gui/qml/components/PaneInsetBackground.qml @@ -0,0 +1,33 @@ +import QtQuick 2.6 +import QtQuick.Controls.Material 2.0 + +Rectangle { + Rectangle { + anchors { left: parent.left; top: parent.top; right: parent.right } + height: 1 + color: Qt.darker(Material.background, 1.50) + } + Rectangle { + anchors { left: parent.left; top: parent.top; bottom: parent.bottom } + width: 1 + color: Qt.darker(Material.background, 1.50) + } + Rectangle { + anchors { left: parent.left; bottom: parent.bottom; right: parent.right } + height: 1 + color: Qt.lighter(Material.background, 1.50) + } + Rectangle { + anchors { right: parent.right; top: parent.top; bottom: parent.bottom } + width: 1 + color: Qt.lighter(Material.background, 1.50) + } + color: Qt.darker(Material.background, 1.15) + Image { + source: '../../icons/electrum_lightblue.svg' + anchors.centerIn: parent + sourceSize.width: 128 + sourceSize.height: 128 + opacity: 0.1 + } +} From ead4600da6c6841c7f818d208db977405ff00268 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 25 Mar 2022 00:07:56 +0100 Subject: [PATCH 072/218] UI address list --- electrum/gui/qml/components/Addresses.qml | 143 ++++++++++++++++------ electrum/gui/qml/qeaddresslistmodel.py | 13 +- 2 files changed, 114 insertions(+), 42 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index bfa45fb26..90ea357f8 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -25,58 +25,125 @@ Pane { height: parent.height clip: true model: Daemon.currentWallet.addressModel + currentIndex: -1 section.property: 'type' section.criteria: ViewSection.FullString section.delegate: sectionDelegate - delegate: AbstractButton { + delegate: ItemDelegate { id: delegate width: ListView.view.width height: delegateLayout.height - - background: Rectangle { - color: model.held ? Qt.rgba(1,0,0,0.5) : - model.numtx > 0 && model.balance == 0 ? Qt.rgba(1,1,1,0.25) : - model.type == 'receive' ? Qt.rgba(0,1,0,0.25) : - Qt.rgba(1,0.93,0,0.25) - Rectangle { - height: 1 - width: parent.width - anchors.top: parent.top - border.color: Material.accentColor - visible: model.index > 0 + highlighted: ListView.isCurrentItem + onClicked: ListView.view.currentIndex == index + ? ListView.view.currentIndex = -1 + : ListView.view.currentIndex = index + + states: [ + State { + name: 'normal'; when: !highlighted + PropertyChanges { target: drawer; visible: false } + PropertyChanges { target: labelLabel; maximumLineCount: 2 } + + }, + State { + name: 'highlighted'; when: highlighted + PropertyChanges { target: drawer; visible: true } + PropertyChanges { target: labelLabel; maximumLineCount: 4 } } - } + ] - RowLayout { + + ColumnLayout { id: delegateLayout - x: constants.paddingSmall - spacing: constants.paddingSmall - width: parent.width - 2*constants.paddingSmall - - Label { - font.pixelSize: constants.fontSizeLarge - font.family: FixedFont - text: model.address - elide: Text.ElideMiddle - Layout.maximumWidth: delegate.width / 3 +// x: constants.paddingSmall + spacing: 0 + //width: parent.width - 2*constants.paddingSmall + width: parent.width + + Item { + Layout.preferredWidth: 1 + Layout.preferredHeight: constants.paddingTiny } - Label { - font.pixelSize: constants.fontSizeMedium - text: model.label - elide: Text.ElideRight - Layout.minimumWidth: delegate.width / 3 - Layout.fillWidth: true + + GridLayout { + columns: 2 + Label { + id: indexLabel + font.pixelSize: constants.fontSizeMedium + font.bold: true + text: '#' + ('00'+model.iaddr).slice(-2) + Layout.fillWidth: true + } + Label { + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + text: model.address + Layout.fillWidth: true + } + + Rectangle { + Layout.preferredWidth: constants.iconSizeMedium + Layout.preferredHeight: constants.iconSizeMedium + color: model.held + ? Qt.rgba(1,0.93,0,0.75) + : model.numtx > 0 && model.balance == 0 + ? Qt.rgba(0.75,0.75,0.75,1) + : model.type == 'receive' + ? Qt.rgba(0,1,0,0.5) + : Qt.rgba(1,0.93,0,0.25) + } + + RowLayout { + Label { + id: labelLabel + font.pixelSize: model.label != '' ? constants.fontSizeLarge : constants.fontSizeSmall + text: model.label != '' ? model.label : '' + opacity: model.label != '' ? 1.0 : 0.8 + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Label { + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + text: Config.formatSats(model.balance, false) + } + Label { + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + text: Config.baseUnit + ',' + } + Label { + font.pixelSize: constants.fontSizeMedium + text: model.numtx + } + Label { + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + text: qsTr('tx') + } + } } - Label { - font.pixelSize: constants.fontSizeMedium - font.family: FixedFont - text: model.balance + + RowLayout { + id: drawer + Layout.fillWidth: true + Layout.preferredHeight: 50 + + ToolButton { + icon.source: '../../icons/qrcode.png' + icon.color: 'transparent' + icon.width: constants.iconSizeMedium + icon.height: constants.iconSizeMedium + } } - Label { - font.pixelSize: constants.fontSizeMedium - text: model.numtx + + Item { + Layout.preferredWidth: 1 + Layout.preferredHeight: constants.paddingSmall } } } diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index a9b60fd7b..5549c87b4 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -15,7 +15,7 @@ class QEAddressListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES=('type','address','label','balance','numtx', 'held') + _ROLE_NAMES=('type','iaddr','address','label','balance','numtx', 'held') _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])) @@ -51,7 +51,7 @@ class QEAddressListModel(QAbstractListModel): c_addresses = self.wallet.get_change_addresses() n_addresses = len(r_addresses) + len(c_addresses) - def insert_row(atype, alist, address): + def insert_row(atype, alist, address, iaddr): item = {} item['type'] = atype item['address'] = address @@ -61,12 +61,17 @@ class QEAddressListModel(QAbstractListModel): item['balance'] = c + u + x item['held'] = self.wallet.is_frozen_address(address) alist.append(item) + item['iaddr'] = iaddr self.clear() self.beginInsertRows(QModelIndex(), 0, n_addresses - 1) + i = 0 for address in r_addresses: - insert_row('receive', self.receive_addresses, address) + insert_row('receive', self.receive_addresses, address, i) + i = i + 1 + i = 0 for address in c_addresses: - insert_row('change', self.change_addresses, address) + insert_row('change', self.change_addresses, address, i) + i = i + 1 self.endInsertRows() From d427be70b2db2989b4fcee9196d3f0bc4f18b8cd Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 25 Mar 2022 09:15:23 +0100 Subject: [PATCH 073/218] move wallet name to qewallet --- electrum/gui/qml/components/Addresses.qml | 2 +- electrum/gui/qml/components/WalletMainView.qml | 2 +- electrum/gui/qml/components/Wallets.qml | 2 +- electrum/gui/qml/qedaemon.py | 6 ------ electrum/gui/qml/qewallet.py | 5 +++++ 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index 90ea357f8..65ef50417 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -8,7 +8,7 @@ import org.electrum 1.0 Pane { id: rootItem - property string title: Daemon.walletName + ' - ' + qsTr('Addresses') + property string title: Daemon.currentWallet.name + ' - ' + qsTr('Addresses') ColumnLayout { id: layout diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index f899e0567..c21faecbb 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -6,7 +6,7 @@ import QtQml 2.6 Item { id: rootItem - property string title: Daemon.walletName + property string title: Daemon.currentWallet.name property QtObject menu: Menu { id: menu diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 337dd8848..d4ca72a14 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -26,7 +26,7 @@ Pane { columns: 4 Label { text: 'Wallet'; Layout.columnSpan: 2 } - Label { text: Daemon.walletName; Layout.columnSpan: 2; color: Material.accentColor } + Label { text: Daemon.currentWallet.name; Layout.columnSpan: 2; color: Material.accentColor } Label { text: 'derivation path (BIP32)'; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } Label { text: Daemon.currentWallet.derivationPath; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 4402ee708..d31f16bb2 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -135,12 +135,6 @@ class QEDaemon(QObject): def currentWallet(self): return self._current_wallet - @pyqtProperty('QString', notify=walletLoaded) - def walletName(self): - if self._current_wallet != None: - return self._current_wallet.wallet.basename() - return '' - @pyqtProperty(QEWalletListModel, notify=activeWalletsChanged) def activeWallets(self): return self._loaded_wallets diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 7e0a6b40e..e694e8894 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -56,6 +56,11 @@ class QEWallet(QObject): def requestModel(self): return self._requestModel + nameChanged = pyqtSignal() + @pyqtProperty('QString', notify=nameChanged) + def name(self): + return self.wallet.basename() + @pyqtProperty('QString', notify=dataChanged) def txinType(self): return self.wallet.get_txin_type(self.wallet.dummy_address()) From 16a2d0c7fb00e0439add5c341f86150506c42974 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 25 Mar 2022 09:29:58 +0100 Subject: [PATCH 074/218] add PT Mono bold --- electrum/gui/qml/fonts/PTMono-Bold.ttf | Bin 0 -> 152676 bytes ...Mono-Regular.ttf.LICENSE => PTMono.LICENSE} | 0 electrum/gui/qml/qeapp.py | 8 ++++---- 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 electrum/gui/qml/fonts/PTMono-Bold.ttf rename electrum/gui/qml/fonts/{PTMono-Regular.ttf.LICENSE => PTMono.LICENSE} (100%) diff --git a/electrum/gui/qml/fonts/PTMono-Bold.ttf b/electrum/gui/qml/fonts/PTMono-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b1a145e0e80e4345c0314cae68a92415911a77c7 GIT binary patch literal 152676 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ˁ`)TU9FooLW}@YIOx?0 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%0A_q4*uy=GkPbBDzMHpgAiEy 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 literal 0 HcmV?d00001 diff --git a/electrum/gui/qml/fonts/PTMono-Regular.ttf.LICENSE b/electrum/gui/qml/fonts/PTMono.LICENSE similarity index 100% rename from electrum/gui/qml/fonts/PTMono-Regular.ttf.LICENSE rename to electrum/gui/qml/fonts/PTMono.LICENSE diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index d97765340..850573523 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -40,11 +40,11 @@ class ElectrumQmlApplication(QGuiApplication): self.engine.addImageProvider('qrgen', self.qr_ip) # add a monospace font as we can't rely on device having one + self.fixedFont = 'PT Mono' if QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Regular.ttf') < 0: - self.logger.warning('Could not load font PTMono-Regular.ttf') - self.fixedFont = 'Monospace' # hope for the best - else: - self.fixedFont = 'PT Mono' + if QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Bold.ttf') < 0: + self.logger.warning('Could not load font PT Mono') + self.fixedFont = 'Monospace' # hope for the best self.context = self.engine.rootContext() self._singletons['config'] = QEConfig(config) From f60eca054d9ee5da92297eb960bbb8ec21684581 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 25 Mar 2022 10:40:58 +0100 Subject: [PATCH 075/218] add watch-only indicator use default state instead of named state set font defaults where it is convenient --- electrum/gui/qml/components/Addresses.qml | 18 +++++------------- electrum/gui/qml/components/OpenWallet.qml | 2 +- electrum/gui/qml/components/Receive.qml | 8 ++------ electrum/gui/qml/components/main.qml | 16 ++++++++++++---- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index 65ef50417..99ec08d5d 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -36,17 +36,14 @@ Pane { width: ListView.view.width height: delegateLayout.height highlighted: ListView.isCurrentItem + + font.pixelSize: constants.fontSizeMedium // set default font size for child controls + onClicked: ListView.view.currentIndex == index ? ListView.view.currentIndex = -1 : ListView.view.currentIndex = index states: [ - State { - name: 'normal'; when: !highlighted - PropertyChanges { target: drawer; visible: false } - PropertyChanges { target: labelLabel; maximumLineCount: 2 } - - }, State { name: 'highlighted'; when: highlighted PropertyChanges { target: drawer; visible: true } @@ -54,7 +51,6 @@ Pane { } ] - ColumnLayout { id: delegateLayout // x: constants.paddingSmall @@ -71,19 +67,18 @@ Pane { columns: 2 Label { id: indexLabel - font.pixelSize: constants.fontSizeMedium font.bold: true text: '#' + ('00'+model.iaddr).slice(-2) Layout.fillWidth: true } Label { - font.pixelSize: constants.fontSizeMedium font.family: FixedFont text: model.address Layout.fillWidth: true } Rectangle { + id: useIndicator Layout.preferredWidth: constants.iconSizeMedium Layout.preferredHeight: constants.iconSizeMedium color: model.held @@ -107,21 +102,17 @@ Pane { Layout.fillWidth: true } Label { - font.pixelSize: constants.fontSizeMedium font.family: FixedFont text: Config.formatSats(model.balance, false) } Label { - font.pixelSize: constants.fontSizeMedium color: Material.accentColor text: Config.baseUnit + ',' } Label { - font.pixelSize: constants.fontSizeMedium text: model.numtx } Label { - font.pixelSize: constants.fontSizeMedium color: Material.accentColor text: qsTr('tx') } @@ -130,6 +121,7 @@ Pane { RowLayout { id: drawer + visible: false Layout.fillWidth: true Layout.preferredHeight: 50 diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml index 55b510cae..ea59d3030 100644 --- a/electrum/gui/qml/components/OpenWallet.qml +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -97,7 +97,7 @@ Pane { } onReadyChanged: { if (ready) { - Daemon.load_wallet(Daemon.path, password.text) + Daemon.load_wallet(openwalletdialog.path, password.text) app.stack.pop(null) } } diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index ecf50dfbc..eafec5ed3 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -193,6 +193,8 @@ Pane { onClicked: console.log('Request ' + index + ' clicked') + font.pixelSize: constants.fontSizeSmall // set default font size for child controls + GridLayout { id: item @@ -227,31 +229,25 @@ Pane { Label { text: qsTr('Amount: ') - font.pixelSize: constants.fontSizeSmall } Label { id: amount text: Config.formatSats(model.amount, true) font.family: FixedFont - 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 } Rectangle { Layout.columnSpan: 5 diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 4ec96db83..d3fb48f74 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -78,12 +78,20 @@ ApplicationWindow } } + Image { + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + visible: Daemon.currentWallet.isWatchOnly + source: '../../icons/eye1.png' + scale: 1.5 + } + Image { Layout.preferredWidth: constants.iconSizeSmall Layout.preferredHeight: constants.iconSizeSmall source: Network.status == 'connecting' || Network.status == 'disconnected' - ? '../../icons/status_disconnected.png' : - Daemon.currentWallet.isUptodate + ? '../../icons/status_disconnected.png' + : Daemon.currentWallet.isUptodate ? '../../icons/status_connected.png' : '../../icons/status_lagging.png' } @@ -97,8 +105,8 @@ ApplicationWindow ToolButton { id: menuButton - visible: stack.currentItem.menu !== undefined && stack.currentItem.menu.count > 0 - text: qsTr("⋮") + enabled: stack.currentItem.menu !== undefined && stack.currentItem.menu.count > 0 + text: enabled ? qsTr("≡") : '' onClicked: { stack.currentItem.menu.open() // position the menu to the right From 62009c647e4b30cb797e011678d11b99d0694234 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 25 Mar 2022 11:45:49 +0100 Subject: [PATCH 076/218] add buttons in address drawers also copies two kivy icons to gui/icons --- electrum/gui/icons/globe.png | Bin 0 -> 5938 bytes electrum/gui/icons/mail_icon.png | Bin 0 -> 4548 bytes electrum/gui/qml/components/Addresses.qml | 82 ++++++++++++++++++---- 3 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 electrum/gui/icons/globe.png create mode 100644 electrum/gui/icons/mail_icon.png diff --git a/electrum/gui/icons/globe.png b/electrum/gui/icons/globe.png new file mode 100644 index 0000000000000000000000000000000000000000..d56382d0c7a70f04c09eee651e3372eaa16d9d48 GIT binary patch literal 5938 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 ikjR7}fkY+*Is6CYx0S+mH^ 0 } Label { color: Material.accentColor text: Config.baseUnit + ',' + visible: model.balance > 0 } Label { text: model.numtx + visible: model.numtx > 0 } Label { color: Material.accentColor text: qsTr('tx') + visible: model.numtx > 0 } } } @@ -123,13 +126,57 @@ Pane { id: drawer visible: false Layout.fillWidth: true - Layout.preferredHeight: 50 + Layout.preferredHeight: copyButton.height ToolButton { - icon.source: '../../icons/qrcode.png' + id: copyButton + icon.source: '../../icons/copy.png' + icon.color: 'transparent' + icon.width: constants.iconSizeMedium + icon.height: constants.iconSizeMedium + onClicked: console.log('TODO: copy address') + } + ToolButton { + icon.source: '../../icons/info.png' + icon.color: 'transparent' + icon.width: constants.iconSizeMedium + icon.height: constants.iconSizeMedium + onClicked: console.log('TODO: show details screen') + } + ToolButton { + icon.source: '../../icons/key.png' + icon.color: 'transparent' + icon.width: constants.iconSizeMedium + icon.height: constants.iconSizeMedium + onClicked: console.log('TODO: sign/verify dialog') + } + ToolButton { + icon.source: '../../icons/mail_icon.png' + icon.color: 'transparent' + icon.width: constants.iconSizeMedium + icon.height: constants.iconSizeMedium + onClicked: console.log('TODO: encrypt/decrypt message dialog') + } + ToolButton { + icon.source: '../../icons/globe.png' + icon.color: 'transparent' + icon.width: constants.iconSizeMedium + icon.height: constants.iconSizeMedium + onClicked: console.log('TODO: show on block explorer') + } + ToolButton { + icon.source: '../../icons/unlock.png' icon.color: 'transparent' icon.width: constants.iconSizeMedium icon.height: constants.iconSizeMedium + onClicked: console.log('TODO: freeze/unfreeze') + } + ToolButton { + icon.source: '../../icons/tab_send.png' + icon.color: 'transparent' + icon.width: constants.iconSizeMedium + icon.height: constants.iconSizeMedium + onClicked: console.log('TODO: spend from address') } } @@ -156,16 +203,25 @@ Pane { required property string section - GridLayout { + RowLayout { + x: constants.paddingMedium + width: parent.width - 2 * constants.paddingMedium + + Rectangle { + Layout.preferredHeight: 1 + Layout.fillWidth: true + color: Material.accentColor + } Label { - topPadding: constants.paddingMedium - bottomPadding: constants.paddingMedium + padding: constants.paddingMedium text: root.section + ' ' + qsTr('addresses') font.bold: true - font.pixelSize: constants.fontSizeLarge + font.pixelSize: constants.fontSizeMedium } - ToolButton { - + Rectangle { + Layout.preferredHeight: 1 + Layout.fillWidth: true + color: Material.accentColor } } } From 758a30462e43ffb1741262345615445325588e47 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 29 Mar 2022 16:36:20 +0200 Subject: [PATCH 077/218] implement QR code scanning --- contrib/android/buildozer_qml.spec | 3 +- electrum/gui/qml/components/QRScan.qml | 137 +++++++++++++++++++++++-- electrum/gui/qml/components/Scan.qml | 12 +++ electrum/gui/qml/components/Send.qml | 8 +- electrum/gui/qml/qeqr.py | 110 ++++++++++++++------ electrum/qrreader/zbar.py | 5 +- 6 files changed, 226 insertions(+), 49 deletions(-) diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec index 6eb434298..b012034a0 100644 --- a/contrib/android/buildozer_qml.spec +++ b/contrib/android/buildozer_qml.spec @@ -52,7 +52,8 @@ requirements = cryptography, pyqt5sip, pyqt5, - pillow + pillow, + libzbar # (str) Presplash of the application #presplash.filename = %(source.dir)s/gui/kivy/theming/splash.png diff --git a/electrum/gui/qml/components/QRScan.qml b/electrum/gui/qml/components/QRScan.qml index f04c4aa57..b0d369deb 100644 --- a/electrum/gui/qml/components/QRScan.qml +++ b/electrum/gui/qml/components/QRScan.qml @@ -1,25 +1,91 @@ -import QtQuick 2.6 +import QtQuick 2.12 +import QtQuick.Controls 2.0 import QtMultimedia 5.6 Item { + id: scanner + + property bool active: false + property string url + property string scanData + + property bool _pointsVisible + + signal found VideoOutput { id: vo anchors.fill: parent source: camera fillMode: VideoOutput.PreserveAspectCrop + + Rectangle { + width: parent.width + height: (parent.height - parent.width) / 2 + anchors.top: parent.top + color: Qt.rgba(0,0,0,0.5) + } + Rectangle { + width: parent.width + height: (parent.height - parent.width) / 2 + anchors.bottom: parent.bottom + color: Qt.rgba(0,0,0,0.5) + } } - MouseArea { - anchors.fill: parent - onClicked: { - vo.grabToImage(function(result) { - console.log("grab: image=" + (result.image !== undefined) + " url=" + result.url) - if (result.image !== undefined) { - console.log('scanning image for QR') - QR.scanImage(result.image) - } - }) + Image { + id: still + anchors.fill: vo + } + + SequentialAnimation { + id: foundAnimation + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: false} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: false} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: false} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + onFinished: found() + } + + Component { + id: r + Rectangle { + property int cx + property int cy + width: 15 + height: 15 + x: cx - width/2 + y: cy - height/2 + radius: 5 + visible: scanner._pointsVisible + } + } + + Connections { + target: QR + function onDataChanged() { + console.log(QR.data) + scanner.active = false + scanner.scanData = QR.data + still.source = scanner.url + + var sx = still.width/still.sourceSize.width + var sy = still.height/still.sourceSize.height + r.createObject(scanner, {cx: QR.points[0].x * sx, cy: QR.points[0].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: QR.points[1].x * sx, cy: QR.points[1].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: QR.points[2].x * sx, cy: QR.points[2].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: QR.points[3].x * sx, cy: QR.points[3].y * sy, color: 'yellow'}) + + foundAnimation.start() } } @@ -28,6 +94,12 @@ Item { deviceId: QtMultimedia.defaultCamera.deviceId viewfinder.resolution: "640x480" + focus { + focusMode: Camera.FocusContinuous + focusPointMode: Camera.FocusPointCustom + customFocusPoint: Qt.point(0.5, 0.5) + } + function dumpstats() { console.log(camera.viewfinder.resolution) console.log(camera.viewfinder.minimumFrameRate) @@ -36,6 +108,49 @@ Item { resolutions.forEach(function(item, i) { console.log('' + item.width + 'x' + item.height) }) + // TODO + // pick a suitable resolution from the available resolutions + // problem: some cameras have no supportedViewfinderResolutions + // but still error out when an invalid resolution is set. + // 640x480 seems to be universally available, but this needs to + // be checked across a range of phone models. } } + + Timer { + id: scanTimer + interval: 200 + repeat: true + running: scanner.active + onTriggered: { + if (QR.busy) + return + vo.grabToImage(function(result) { + if (result.image !== undefined) { + scanner.url = result.url + QR.scanImage(result.image) + } else { + console.log('image grab returned null') + } + }) + } + } + + Component.onCompleted: { + console.log('Scan page initialized') + QtMultimedia.availableCameras.forEach(function(item) { + console.log('cam found') + console.log(item.deviceId) + console.log(item.displayName) + console.log(item.position) + console.log(item.orientation) + if (QtMultimedia.defaultCamera.deviceId == item.deviceId) { + vo.orientation = item.orientation + } + + camera.dumpstats() + }) + + active = true + } } diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml index 734e7d6a5..35f18845a 100644 --- a/electrum/gui/qml/components/Scan.qml +++ b/electrum/gui/qml/components/Scan.qml @@ -2,13 +2,25 @@ import QtQuick 2.6 import QtQuick.Controls 2.0 Item { + id: scanPage + property string title: qsTr('Scan') property bool toolbar: false + property string scanData + + signal found + QRScan { anchors.top: parent.top anchors.bottom: parent.bottom width: parent.width + + onFound: { + scanPage.scanData = scanData + scanPage.found() + app.stack.pop() + } } Button { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 81b3d1362..534cf92f6 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -80,7 +80,13 @@ Pane { Button { text: qsTr('Scan QR Code') - onClicked: app.stack.push(Qt.resolvedUrl('Scan.qml')) + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) + page.onFound.connect(function() { + console.log('got ' + page.scanData) + address.text = page.scanData + }) + } } } } diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index d93323733..5cc72bd26 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -1,42 +1,54 @@ -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtGui import QImage +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QRect, QPoint +from PyQt5.QtGui import QImage,QColor from PyQt5.QtQuick import QQuickImageProvider from electrum.logging import get_logger +from electrum.qrreader import get_qr_reader +from electrum.i18n import _ import qrcode #from qrcode.image.styledpil import StyledPilImage #from qrcode.image.styles.moduledrawers import * from PIL import Image, ImageQt - from ctypes import * +import sys class QEQR(QObject): def __init__(self, text=None, parent=None): super().__init__(parent) self._text = text + self.qrreader = get_qr_reader() + if not self.qrreader: + raise Exception(_("The platform QR detection library is not available.")) _logger = get_logger(__name__) - scanReadyChanged = pyqtSignal() + busyChanged = pyqtSignal() + dataChanged = pyqtSignal() imageChanged = pyqtSignal() - _scanReady = True + _busy = False _image = None @pyqtSlot('QImage') def scanImage(self, image=None): - if not self._scanReady: - self._logger.warning("Already processing an image. Check 'ready' property before calling scanImage") + if self._busy: + self._logger.warning("Already processing an image. Check 'busy' property before calling scanImage") + return + + if image == None: + self._logger.warning("No image to decode") return - self._scanReady = False - self.scanReadyChanged.emit() - pilimage = self.convertToPILImage(image) - self.parseQR(pilimage) + self._busy = True + self.busyChanged.emit() - self._scanReady = True + self.logImageStats(image) + self._parseQR(image) + + self._busy = False + self.busyChanged.emit() def logImageStats(self, image): self._logger.info('width: ' + str(image.width())) @@ -44,33 +56,63 @@ class QEQR(QObject): self._logger.info('depth: ' + str(image.depth())) self._logger.info('format: ' + str(image.format())) - def convertToPILImage(self, image): # -> Image: - self.logImageStats(image) - - rawimage = image.constBits() - # assumption: pixels are 32 bits ARGB - numbytes = image.width() * image.height() * 4 - - self._logger.info(type(rawimage)) - buf = bytearray(numbytes) - c_buf = (c_byte * numbytes).from_buffer(buf) - memmove(c_buf, c_void_p(rawimage.__int__()), numbytes) - buf2 = bytes(buf) - - return Image.frombytes('RGBA', (image.width(), image.height()), buf2, 'raw') - - def parseQR(self, image): - # TODO - pass - - @pyqtProperty(bool, notify=scanReadyChanged) - def scanReady(self): - return self._scanReady + def _parseQR(self, image): + self.w = image.width() + self.h = image.height() + img_crop_rect = self._get_crop(image, 360) + frame_cropped = image.copy(img_crop_rect) + + # Convert to Y800 / GREY FourCC (single 8-bit channel) + # This creates a copy, so we don't need to keep the frame around anymore + frame_y800 = frame_cropped.convertToFormat(QImage.Format_Grayscale8) + + self.frame_id = 0 + # Read the QR codes from the frame + self.qrreader_res = self.qrreader.read_qr_code( + frame_y800.constBits().__int__(), frame_y800.byteCount(), + frame_y800.bytesPerLine(), + frame_y800.width(), + frame_y800.height(), self.frame_id + ) + + if len(self.qrreader_res) > 0: + result = self.qrreader_res[0] + self._data = result + self.dataChanged.emit() + + def _get_crop(self, image: QImage, scan_size: int) -> QRect: + """ + Returns a QRect that is scan_size x scan_size in the middle of the resolution + """ + self.scan_pos_x = (image.width() - scan_size) // 2 + self.scan_pos_y = (image.height() - scan_size) // 2 + return QRect(self.scan_pos_x, self.scan_pos_y, scan_size, scan_size) + + @pyqtProperty(bool, notify=busyChanged) + def busy(self): + return self._busy @pyqtProperty('QImage', notify=imageChanged) def image(self): return self._image + @pyqtProperty(str, notify=dataChanged) + def data(self): + return self._data.data + + @pyqtProperty('QPoint', notify=dataChanged) + def center(self): + (x,y) = self._data.center + return QPoint(x+self.scan_pos_x, y+self.scan_pos_y) + + @pyqtProperty('QVariant', notify=dataChanged) + def points(self): + result = [] + for item in self._data.points: + (x,y) = item + result.append(QPoint(x+self.scan_pos_x, y+self.scan_pos_y)) + return result + class QEQRImageProvider(QQuickImageProvider): def __init__(self, parent=None): super().__init__(QQuickImageProvider.Image) diff --git a/electrum/qrreader/zbar.py b/electrum/qrreader/zbar.py index 14df4e70f..8a3ef54df 100644 --- a/electrum/qrreader/zbar.py +++ b/electrum/qrreader/zbar.py @@ -37,8 +37,9 @@ from .abstract_base import AbstractQrCodeReader, QrCodeResult _logger = get_logger(__name__) - -if sys.platform == 'darwin': +if 'ANDROID_DATA' in os.environ: + LIBNAME = 'libzbar.so' +elif sys.platform == 'darwin': LIBNAME = 'libzbar.0.dylib' elif sys.platform in ('windows', 'win32'): LIBNAME = 'libzbar-0.dll' From 756cd9706f8f922a10c2afb08d71d54245ff4026 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 29 Mar 2022 16:54:20 +0200 Subject: [PATCH 078/218] use fixed font on amount, address fields --- electrum/gui/qml/components/Receive.qml | 4 +++- electrum/gui/qml/components/Send.qml | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index eafec5ed3..dbb213db3 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -1,6 +1,6 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.0 +import QtQuick.Controls 2.14 import QtQuick.Controls.Material 2.0 import org.electrum 1.0 @@ -36,6 +36,7 @@ Pane { TextField { id: amount + font.family: FixedFont Layout.fillWidth: true } @@ -78,6 +79,7 @@ Pane { TextField { id: amountFiat + font.family: FixedFont Layout.fillWidth: true } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 534cf92f6..f866d21ed 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -23,6 +23,7 @@ Pane { id: address Layout.columnSpan: 2 Layout.fillWidth: true + font.family: FixedFont placeholderText: qsTr('Paste address or invoice') } @@ -39,6 +40,7 @@ Pane { TextField { id: amount + font.family: FixedFont placeholderText: qsTr('Amount') } @@ -56,6 +58,7 @@ Pane { TextField { id: fee + font.family: FixedFont placeholderText: qsTr('sat/vB') Layout.columnSpan: 3 } From 1609fe8663e4e521185ab83a8b96141f0c31cdca Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 29 Mar 2022 17:28:04 +0200 Subject: [PATCH 079/218] parse QR async --- electrum/gui/qml/qeqr.py | 59 ++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index 5cc72bd26..cbc177c33 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -2,17 +2,16 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QRec from PyQt5.QtGui import QImage,QColor from PyQt5.QtQuick import QQuickImageProvider -from electrum.logging import get_logger -from electrum.qrreader import get_qr_reader - -from electrum.i18n import _ +import asyncio import qrcode #from qrcode.image.styledpil import StyledPilImage #from qrcode.image.styles.moduledrawers import * - from PIL import Image, ImageQt -from ctypes import * -import sys + +from electrum.logging import get_logger +from electrum.qrreader import get_qr_reader +from electrum.i18n import _ + class QEQR(QObject): def __init__(self, text=None, parent=None): @@ -47,9 +46,6 @@ class QEQR(QObject): self.logImageStats(image) self._parseQR(image) - self._busy = False - self.busyChanged.emit() - def logImageStats(self, image): self._logger.info('width: ' + str(image.width())) self._logger.info('height: ' + str(image.height())) @@ -62,23 +58,32 @@ class QEQR(QObject): img_crop_rect = self._get_crop(image, 360) frame_cropped = image.copy(img_crop_rect) - # Convert to Y800 / GREY FourCC (single 8-bit channel) - # This creates a copy, so we don't need to keep the frame around anymore - frame_y800 = frame_cropped.convertToFormat(QImage.Format_Grayscale8) - - self.frame_id = 0 - # Read the QR codes from the frame - self.qrreader_res = self.qrreader.read_qr_code( - frame_y800.constBits().__int__(), frame_y800.byteCount(), - frame_y800.bytesPerLine(), - frame_y800.width(), - frame_y800.height(), self.frame_id - ) - - if len(self.qrreader_res) > 0: - result = self.qrreader_res[0] - self._data = result - self.dataChanged.emit() + async def co_parse_qr(image): + # Convert to Y800 / GREY FourCC (single 8-bit channel) + # This creates a copy, so we don't need to keep the frame around anymore + frame_y800 = image.convertToFormat(QImage.Format_Grayscale8) + + self.frame_id = 0 + # Read the QR codes from the frame + self.qrreader_res = self.qrreader.read_qr_code( + frame_y800.constBits().__int__(), + frame_y800.byteCount(), + frame_y800.bytesPerLine(), + frame_y800.width(), + frame_y800.height(), + self.frame_id + ) + + if len(self.qrreader_res) > 0: + result = self.qrreader_res[0] + self._data = result + self.dataChanged.emit() + + self._busy = False + self.busyChanged.emit() + + loop = asyncio.get_event_loop() + asyncio.run_coroutine_threadsafe(co_parse_qr(frame_cropped), loop) def _get_crop(self, image: QImage, scan_size: int) -> QRect: """ From 490862d09662d550eadf131279d5f5d2d7a75e50 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 17:37:22 +0200 Subject: [PATCH 080/218] add RequestDialog, open request on create, and implement UI delete request --- electrum/gui/qml/components/Receive.qml | 176 ++++++++++++++---------- electrum/gui/qml/qerequestlistmodel.py | 19 ++- electrum/gui/qml/qewallet.py | 7 +- 3 files changed, 122 insertions(+), 80 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index dbb213db3..bbc1a1139 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -2,6 +2,7 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.14 import QtQuick.Controls.Material 2.0 +import QtQml.Models 2.1 import org.electrum 1.0 @@ -182,91 +183,99 @@ Pane { } ListView { + id: listview Layout.fillHeight: true Layout.fillWidth: true clip: true - model: Daemon.currentWallet.requestModel + model: DelegateModel { + id: delegateModel + model: Daemon.currentWallet.requestModel - delegate: ItemDelegate { - id: root - height: item.height - width: ListView.view.width + delegate: ItemDelegate { + id: root + height: item.height + width: ListView.view.width - onClicked: console.log('Request ' + index + ' clicked') - - font.pixelSize: constants.fontSizeSmall // set default font size for child controls - - GridLayout { - id: item - - anchors { - left: parent.left - right: parent.right - leftMargin: constants.paddingSmall - rightMargin: constants.paddingSmall - } - - columns: 5 - - Rectangle { - Layout.columnSpan: 5 - Layout.fillWidth: true - Layout.preferredHeight: constants.paddingTiny - color: 'transparent' - } - 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 - elide: Text.ElideRight - font.pixelSize: constants.fontSizeLarge + onClicked: { + var dialog = requestdialog.createObject(app, {'modelItem': model}) + dialog.open() } - Label { - text: qsTr('Amount: ') - } - Label { - id: amount - text: Config.formatSats(model.amount, true) - font.family: FixedFont + font.pixelSize: constants.fontSizeSmall // set default font size for child controls + + GridLayout { + id: item + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingSmall + rightMargin: constants.paddingSmall + } + + columns: 5 + + Rectangle { + Layout.columnSpan: 5 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + Image { + Layout.rowSpan: 2 + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + source: model.type == 0 ? "../../icons/bitcoin.png" : "../../icons/lightning.png" + } + Label { + Layout.fillWidth: true + Layout.columnSpan: 2 + text: model.message + elide: Text.ElideRight + font.pixelSize: constants.fontSizeLarge + } + + Label { + text: qsTr('Amount: ') + } + Label { + id: amount + text: Config.formatSats(model.amount, true) + font.family: FixedFont + } + + Label { + text: qsTr('Timestamp: ') + } + Label { + text: model.timestamp + } + + Label { + text: qsTr('Status: ') + } + Label { + text: model.status + } + Rectangle { + Layout.columnSpan: 5 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } } - Label { - text: qsTr('Timestamp: ') - } - Label { - text: model.timestamp + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = Config.formatSats(model.amount, true) + } + function onThousandsSeparatorChanged() { + amount.text = Config.formatSats(model.amount, true) + } } - Label { - text: qsTr('Status: ') - } - Label { - text: model.status - } - Rectangle { - Layout.columnSpan: 5 - Layout.fillWidth: true - Layout.preferredHeight: constants.paddingTiny - color: 'transparent' - } - } - - Connections { - target: Config - function onBaseUnitChanged() { - amount.text = Config.formatSats(model.amount, true) - } - function onThousandsSeparatorChanged() { - amount.text = Config.formatSats(model.amount, true) - } } } @@ -280,6 +289,14 @@ Pane { NumberAnimation { properties: 'opacity'; to: 1.0; duration: 700 * (1-from) } } + remove: Transition { + NumberAnimation { properties: 'scale'; to: 0; duration: 400 } + NumberAnimation { properties: 'opacity'; to: 0; duration: 300 } + } + removeDisplaced: Transition { + SpringAnimation { properties: 'y'; duration: 100; spring: 5; damping: 0.5; mass: 2 } + } + ScrollIndicator.vertical: ScrollIndicator { } } } @@ -294,9 +311,14 @@ Pane { FocusScope { id: parkFocus } } + Component { + id: requestdialog + RequestDialog {} + } + function createRequest(ignoreGaplimit = false) { var a = Config.unitsToSats(amount.text) - Daemon.currentWallet.create_invoice(a, message.text, expires.currentValue, false, ignoreGaplimit) + Daemon.currentWallet.create_request(a, message.text, expires.currentValue, false, ignoreGaplimit) } Connections { @@ -304,8 +326,10 @@ Pane { function onRequestCreateSuccess() { message.text = '' amount.text = '' -// var dialog = app.showAsQrDialog.createObject(app, {'text': 'test'}) -// dialog.open() + var dialog = requestdialog.createObject(app, { + 'modelItem': delegateModel.items.get(0).model + }) + dialog.open() } function onRequestCreateError(code, error) { if (code == 'gaplimit') { diff --git a/electrum/gui/qml/qerequestlistmodel.py b/electrum/gui/qml/qerequestlistmodel.py index 667b93865..f0cb65fb6 100644 --- a/electrum/gui/qml/qerequestlistmodel.py +++ b/electrum/gui/qml/qerequestlistmodel.py @@ -14,7 +14,7 @@ class QERequestListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES=('type','timestamp','message','amount','status') + _ROLE_NAMES=('key','type','timestamp','message','amount','status','address') _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])) @@ -49,8 +49,11 @@ class QERequestListModel(QAbstractListModel): 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 "" + if req.type == 0: # OnchainInvoice + item['key'] = item['address'] = req.get_address() + elif req.type == 2: # LNInvoice + #item['key'] = req.getrhash() + pass return item @@ -74,3 +77,13 @@ class QERequestListModel(QAbstractListModel): self.beginInsertRows(QModelIndex(), 0, 0) self.requests.insert(0, item) self.endInsertRows() + + def delete_request(self, key: str): + i = 0 + for request in self.requests: + if request['key'] == key: + self.beginRemoveRows(QModelIndex(), i, i) + self.requests.pop(i) + self.endRemoveRows() + break + i = i + 1 diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index e694e8894..59a134ad3 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -164,7 +164,7 @@ class QEWallet(QObject): @pyqtSlot(int, 'QString', int) @pyqtSlot(int, 'QString', int, bool) @pyqtSlot(int, 'QString', int, bool, bool) - def create_invoice(self, amount: int, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False): + def create_request(self, amount: int, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False): expiry = expiration #TODO: self.config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING) try: if is_lightning: @@ -190,3 +190,8 @@ class QEWallet(QObject): #content = r.invoice if r.is_lightning() else r.get_address() #title = _('Invoice') if is_lightning else _('Address') #self.do_copy(content, title=title) + + @pyqtSlot('QString') + def delete_request(self, key: str): + self.wallet.delete_request(key) + self._requestModel.delete_request(key) From da727278fa06ba4cef32bfe24ef4b5ea313111da Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 17:38:58 +0200 Subject: [PATCH 081/218] small fixes --- electrum/gui/qml/components/BalanceSummary.qml | 5 +---- electrum/gui/qml/components/main.qml | 1 + electrum/gui/qml/components/wizard/WCConfirmSeed.qml | 1 - electrum/gui/qml/components/wizard/WCCreateSeed.qml | 1 - electrum/gui/qml/components/wizard/WCHaveSeed.qml | 1 - electrum/gui/qml/components/wizard/Wizard.qml | 2 +- 6 files changed, 3 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index f5170dabe..24c664300 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -6,6 +6,7 @@ import QtQuick.Controls.Material 2.0 Frame { id: root height: layout.height + font.pixelSize: constants.fontSizeMedium property string formattedBalance property string formattedUnconfirmed @@ -29,20 +30,16 @@ Frame { text: formattedBalance } Label { - font.pixelSize: constants.fontSizeMedium text: qsTr('Confirmed: ') } Label { - font.pixelSize: constants.fontSizeMedium color: Material.accentColor text: formattedBalance } Label { - font.pixelSize: constants.fontSizeMedium text: qsTr('Unconfirmed: ') } Label { - font.pixelSize: constants.fontSizeMedium color: Material.accentColor text: formattedUnconfirmed } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index d3fb48f74..71bbaf2bd 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -107,6 +107,7 @@ ApplicationWindow id: menuButton enabled: stack.currentItem.menu !== undefined && stack.currentItem.menu.count > 0 text: enabled ? qsTr("≡") : '' + font.pixelSize: constants.fontSizeXLarge onClicked: { stack.currentItem.menu.open() // position the menu to the right diff --git a/electrum/gui/qml/components/wizard/WCConfirmSeed.qml b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml index 5fb5b613c..a6b21d81e 100644 --- a/electrum/gui/qml/components/wizard/WCConfirmSeed.qml +++ b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml @@ -44,7 +44,6 @@ WizardComponent { id: customwordstext Layout.fillWidth: true placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password onTextChanged: { checkValid() } diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml index d62614aab..28fe2e0c7 100644 --- a/electrum/gui/qml/components/wizard/WCCreateSeed.qml +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -69,7 +69,6 @@ WizardComponent { visible: extendcb.checked Layout.fillWidth: true placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password } Component.onCompleted : { setWarningText(12) diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml index 87df6262b..b305bc173 100644 --- a/electrum/gui/qml/components/wizard/WCHaveSeed.qml +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -126,7 +126,6 @@ WizardComponent { Layout.fillWidth: true Layout.columnSpan: 2 placeholderText: qsTr('Enter your custom word(s)') - echoMode: TextInput.Password } } } diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml index b89d78291..4ce028ddf 100644 --- a/electrum/gui/qml/components/wizard/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -166,7 +166,7 @@ Dialog { // so the keyboard goes away // TODO: here it works on desktop, but not android. hmm. MouseArea { - anchors.fill: wizard + anchors.fill: parent z: -1000 onClicked: { parkFocus.focus = true } FocusScope { id: parkFocus } From 5e039a215acefe4267483e9e6143f3bcaf7ab8e8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 17:43:15 +0200 Subject: [PATCH 082/218] forgot RequestDialog --- electrum/gui/qml/components/RequestDialog.qml | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 electrum/gui/qml/components/RequestDialog.qml diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml new file mode 100644 index 000000000..0ae02cd1b --- /dev/null +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -0,0 +1,164 @@ +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 + +Dialog { + id: dialog + title: qsTr('Payment Request') + + property var modelItem + + parent: Overlay.overlay + modal: true + + width: parent.width - constants.paddingXLarge + height: parent.height - constants.paddingXLarge + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + + Overlay.modal: Rectangle { + color: "#aa000000" + } + + header: RowLayout { + width: dialog.width + Label { + Layout.fillWidth: true + text: dialog.title + visible: dialog.title + elide: Label.ElideRight + padding: 24 + bottomPadding: 0 + font.bold: true + font.pixelSize: 16 + } + ToolButton { + Layout.alignment: Qt.AlignBaseline + icon.source: '../../icons/closebutton.png' + icon.color: 'transparent' + icon.width: 32 + icon.height: 32 + onClicked: dialog.close() + } + } + GridLayout { + width: parent.width + rowSpacing: constants.paddingMedium + columns: 3 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 3 + color: Material.accentColor + } + + Image { + Layout.columnSpan: 3 + Layout.alignment: Qt.AlignHCenter + source: 'image://qrgen/' + modelItem.address + + Rectangle { + property int size: 58 + color: 'white' + x: (parent.width - size) / 2 + y: (parent.height - size) / 2 + width: size + height: size + + Image { + source: '../../icons/electrum.png' + x: 1 + y: 1 + width: parent.width - 2 + height: parent.height - 2 + } + } + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 3 + color: Material.accentColor + } + + Label { + visible: modelItem.message != '' + text: qsTr('Description') + } + Label { + visible: modelItem.message != '' + Layout.columnSpan: 2 + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: modelItem.message + font.pixelSize: constants.fontSizeLarge + } + + Label { + visible: modelItem.amount > 0 + text: qsTr('Amount') + } + Label { + visible: modelItem.amount > 0 + text: Config.formatSats(modelItem.amount, false) + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + } + Label { + visible: modelItem.amount > 0 + Layout.fillWidth: true + text: Config.baseUnit + color: Material.accentColor + font.pixelSize: constants.fontSizeLarge + } + + Label { + text: qsTr('Address') + } + Label { + Layout.columnSpan: 2 + Layout.fillWidth: true + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + wrapMode: Text.WrapAnywhere + text: modelItem.address + } + + Label { + text: qsTr('Status') + } + Label { + Layout.columnSpan: 2 + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + text: modelItem.status + } + + RowLayout { + Layout.columnSpan: 3 + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter + Button { + text: 'Delete' + onClicked: { + Daemon.currentWallet.delete_request(modelItem.key) + dialog.close() + } + } + Button { + text: 'Copy' + enabled: false + } + Button { + text: 'Share' + enabled: false + } + } + } + +} From 39427452708fe4e896d6dc533f28d02958af0772 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 18:00:36 +0200 Subject: [PATCH 083/218] add input method hints --- electrum/gui/qml/components/Receive.qml | 2 ++ electrum/gui/qml/components/SeedTextArea.qml | 7 ++++--- electrum/gui/qml/components/Send.qml | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index bbc1a1139..b405cebd9 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -39,6 +39,7 @@ Pane { id: amount font.family: FixedFont Layout.fillWidth: true + inputMethodHints: Qt.ImhPreferNumbers } Label { @@ -82,6 +83,7 @@ Pane { id: amountFiat font.family: FixedFont Layout.fillWidth: true + inputMethodHints: Qt.ImhDigitsOnly } Label { diff --git a/electrum/gui/qml/components/SeedTextArea.qml b/electrum/gui/qml/components/SeedTextArea.qml index a6af6b700..34bd866f8 100644 --- a/electrum/gui/qml/components/SeedTextArea.qml +++ b/electrum/gui/qml/components/SeedTextArea.qml @@ -7,11 +7,12 @@ TextArea { id: seedtext Layout.fillWidth: true Layout.minimumHeight: 80 - rightPadding: 16 - leftPadding: 16 + rightPadding: constants.paddingLarge + leftPadding: constants.paddingLarge wrapMode: TextInput.WordWrap font.bold: true - font.pixelSize: 18 + font.pixelSize: constants.fontSizeLarge + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhPreferLowercase | Qt.ImhNoPredictiveText background: Rectangle { color: "transparent" border.color: Material.accentColor diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index f866d21ed..51b993386 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -42,6 +42,7 @@ Pane { id: amount font.family: FixedFont placeholderText: qsTr('Amount') + inputMethodHints: Qt.ImhPreferNumbers } Label { From d1623c5ed3f53d8b8b223174c2d2e8f068f303f2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 18:01:16 +0200 Subject: [PATCH 084/218] QRParser now a type, not a context property --- electrum/gui/qml/components/QRScan.qml | 24 +++++++++++++++--------- electrum/gui/qml/qeapp.py | 5 ++--- electrum/gui/qml/qeqr.py | 6 +++--- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/electrum/gui/qml/components/QRScan.qml b/electrum/gui/qml/components/QRScan.qml index b0d369deb..b681a92ec 100644 --- a/electrum/gui/qml/components/QRScan.qml +++ b/electrum/gui/qml/components/QRScan.qml @@ -2,6 +2,8 @@ import QtQuick 2.12 import QtQuick.Controls 2.0 import QtMultimedia 5.6 +import org.electrum 1.0 + Item { id: scanner @@ -71,19 +73,19 @@ Item { } Connections { - target: QR + target: qr function onDataChanged() { - console.log(QR.data) + console.log(qr.data) scanner.active = false - scanner.scanData = QR.data + scanner.scanData = qr.data still.source = scanner.url var sx = still.width/still.sourceSize.width var sy = still.height/still.sourceSize.height - r.createObject(scanner, {cx: QR.points[0].x * sx, cy: QR.points[0].y * sy, color: 'yellow'}) - r.createObject(scanner, {cx: QR.points[1].x * sx, cy: QR.points[1].y * sy, color: 'yellow'}) - r.createObject(scanner, {cx: QR.points[2].x * sx, cy: QR.points[2].y * sy, color: 'yellow'}) - r.createObject(scanner, {cx: QR.points[3].x * sx, cy: QR.points[3].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: qr.points[0].x * sx, cy: qr.points[0].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: qr.points[1].x * sx, cy: qr.points[1].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: qr.points[2].x * sx, cy: qr.points[2].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: qr.points[3].x * sx, cy: qr.points[3].y * sy, color: 'yellow'}) foundAnimation.start() } @@ -123,12 +125,12 @@ Item { repeat: true running: scanner.active onTriggered: { - if (QR.busy) + if (qr.busy) return vo.grabToImage(function(result) { if (result.image !== undefined) { scanner.url = result.url - QR.scanImage(result.image) + qr.scanImage(result.image) } else { console.log('image grab returned null') } @@ -136,6 +138,10 @@ Item { } } + QRParser { + id: qr + } + Component.onCompleted: { console.log('Scan page initialized') QtMultimedia.availableCameras.forEach(function(item) { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 850573523..6645ea7e7 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -10,7 +10,7 @@ from .qeconfig import QEConfig from .qedaemon import QEDaemon, QEWalletListModel from .qenetwork import QENetwork from .qewallet import QEWallet -from .qeqr import QEQR, QEQRImageProvider +from .qeqr import QEQRParser, QEQRImageProvider from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin @@ -32,6 +32,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet') qmlRegisterType(QEWalletDB, 'org.electrum', 1, 0, 'WalletDB') qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin') + qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser') self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') @@ -50,11 +51,9 @@ class ElectrumQmlApplication(QGuiApplication): self._singletons['config'] = QEConfig(config) self._singletons['network'] = QENetwork(daemon.network) self._singletons['daemon'] = QEDaemon(daemon) - self._singletons['qr'] = QEQR() self.context.setContextProperty('Config', self._singletons['config']) self.context.setContextProperty('Network', self._singletons['network']) self.context.setContextProperty('Daemon', self._singletons['daemon']) - self.context.setContextProperty('QR', self._singletons['qr']) self.context.setContextProperty('FixedFont', self.fixedFont) qInstallMessageHandler(self.message_handler) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index cbc177c33..b32ca1c92 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -13,7 +13,7 @@ from electrum.qrreader import get_qr_reader from electrum.i18n import _ -class QEQR(QObject): +class QEQRParser(QObject): def __init__(self, text=None, parent=None): super().__init__(parent) self._text = text @@ -131,5 +131,5 @@ class QEQRImageProvider(QQuickImageProvider): qr.make(fit=True) pimg = qr.make_image(fill_color='black', back_color='white') #image_factory=StyledPilImage, module_drawer=CircleModuleDrawer()) - qimg = ImageQt.ImageQt(pimg) - return qimg, qimg.size() + self.qimg = ImageQt.ImageQt(pimg) + return self.qimg, self.qimg.size() From 6cf4fc9e1ebebf1984126e61823aac650e894502 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 19:31:14 +0200 Subject: [PATCH 085/218] implement user notifications for new_transaction events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As the QML app can have multiple active wallets managed from a single window (unlike the desktop Qt version), we let each wallet manage its own user notification queue (as there are some rules here specific to each wallet, e.g. not emitting user notifications for each tx while the wallet is still syncing), including collating and rate limiting. The app then consumes the userNotify events from all active wallets, and adds these to its own queue, which get displayed (eventually, again implementing rate limiting) to the user. It also uses timers efficiently, only enabling them if there are actual userNotify events waiting. If at any point the QML app wants to use multiple windows, it can forego on the app user notification queue and instead attach each window to the associated wallet userNotify signal. app ▲ │ │ timer -> userNotify(msg) signal │ ┌──┬───┴───────┐ │ │ │ app user notification queue └──┴───▲───────┘ │ │ timer -> userNotify(wallet, msg) signal │ ┌──┬───┴───────┐ │ │ │ wallet user notification queue └──┴───▲───────┘ │ │ new_transaction │ wallet --- electrum/gui/qml/qeapp.py | 84 ++++++++++++++++++----- electrum/gui/qml/qewallet.py | 127 +++++++++++++++++++++++++++++------ 2 files changed, 174 insertions(+), 37 deletions(-) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 6645ea7e7..83dccbfe2 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -1,6 +1,8 @@ import re +import queue +import time -from PyQt5.QtCore import pyqtSlot, QObject, QUrl, QLocale, qInstallMessageHandler +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl, QLocale, qInstallMessageHandler, QTimer from PyQt5.QtGui import QGuiApplication, QFontDatabase from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine #, QQmlComponent @@ -14,11 +16,66 @@ from .qeqr import QEQRParser, QEQRImageProvider from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin +class QEAppController(QObject): + userNotify = pyqtSignal(str) + + def __init__(self, qedaemon): + super().__init__() + self.logger = get_logger(__name__) + + self._qedaemon = qedaemon + + # set up notification queue and notification_timer + self.user_notification_queue = queue.Queue() + self.user_notification_last_time = 0 + + self.notification_timer = QTimer(self) + self.notification_timer.setSingleShot(False) + self.notification_timer.setInterval(500) # msec + self.notification_timer.timeout.connect(self.on_notification_timer) + + self._qedaemon.walletLoaded.connect(self.on_wallet_loaded) + + def on_wallet_loaded(self): + qewallet = self._qedaemon.currentWallet + # attach to the wallet user notification events + # connect only once + try: + qewallet.userNotify.disconnect(self.on_wallet_usernotify) + except: + pass + qewallet.userNotify.connect(self.on_wallet_usernotify) + + def on_wallet_usernotify(self, wallet, message): + self.logger.debug(message) + self.user_notification_queue.put(message) + if not self.notification_timer.isActive(): + self.logger.debug('starting app notification timer') + self.notification_timer.start() + + def on_notification_timer(self): + if self.user_notification_queue.qsize() == 0: + self.logger.debug('queue empty, stopping app notification timer') + self.notification_timer.stop() + return + now = time.time() + rate_limit = 20 # seconds + if self.user_notification_last_time + rate_limit > now: + return + self.user_notification_last_time = now + self.logger.info("Notifying GUI about new user notifications") + try: + self.userNotify.emit(self.user_notification_queue.get_nowait()) + except queue.Empty: + pass + + @pyqtSlot('QString') + def textToClipboard(self, text): + QGuiApplication.clipboard().setText(text) + class ElectrumQmlApplication(QGuiApplication): - _config = None - _daemon = None - _singletons = {} + _valid = True def __init__(self, args, config, daemon): super().__init__(args) @@ -48,12 +105,14 @@ class ElectrumQmlApplication(QGuiApplication): self.fixedFont = 'Monospace' # hope for the best self.context = self.engine.rootContext() - self._singletons['config'] = QEConfig(config) - self._singletons['network'] = QENetwork(daemon.network) - self._singletons['daemon'] = QEDaemon(daemon) - self.context.setContextProperty('Config', self._singletons['config']) - self.context.setContextProperty('Network', self._singletons['network']) - self.context.setContextProperty('Daemon', self._singletons['daemon']) + self._qeconfig = QEConfig(config) + self._qenetwork = QENetwork(daemon.network) + self._qedaemon = QEDaemon(daemon) + self._appController = QEAppController(self._qedaemon) + self.context.setContextProperty('AppController', self._appController) + self.context.setContextProperty('Config', self._qeconfig) + self.context.setContextProperty('Network', self._qenetwork) + self.context.setContextProperty('Daemon', self._qedaemon) self.context.setContextProperty('FixedFont', self.fixedFont) qInstallMessageHandler(self.message_handler) @@ -61,9 +120,6 @@ class ElectrumQmlApplication(QGuiApplication): # get notified whether root QML document loads or not self.engine.objectCreated.connect(self.objectCreated) - - _valid = True - # slot is called after loading root QML. If object is None, it has failed. @pyqtSlot('QObject*', 'QUrl') def objectCreated(self, object, url): @@ -76,5 +132,3 @@ class ElectrumQmlApplication(QGuiApplication): if re.search('file:///.*TypeError: Cannot read property.*null$', file): return self.logger.warning(file) - - diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 59a134ad3..9ec0511c8 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,6 +1,8 @@ -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl - from typing import Optional, TYPE_CHECKING, Sequence, List, Union +import queue +import time + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer from electrum.i18n import _ from electrum.util import register_callback, Satoshis, format_time @@ -16,6 +18,22 @@ from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel class QEWallet(QObject): + _logger = get_logger(__name__) + + # emitted when wallet wants to display a user notification + # actual presentation should be handled on app or window level + userNotify = pyqtSignal(object, object) + + # shared signal for many static wallet properties + dataChanged = pyqtSignal() + + isUptodateChanged = pyqtSignal() + requestStatus = pyqtSignal() + requestCreateSuccess = pyqtSignal() + requestCreateError = pyqtSignal([str,str], arguments=['code','error']) + + _network_signal = pyqtSignal(str, object) + def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet @@ -26,20 +44,95 @@ class QEWallet(QObject): self._historyModel.init_model() self._requestModel.init_model() - register_callback(self.on_request_status, ['request_status']) - register_callback(self.on_status, ['status']) + self.tx_notification_queue = queue.Queue() + self.tx_notification_last_time = 0 + + self.notification_timer = QTimer(self) + self.notification_timer.setSingleShot(False) + self.notification_timer.setInterval(500) # msec + self.notification_timer.timeout.connect(self.notify_transactions) + + self._network_signal.connect(self.on_network_qt) + interests = ['wallet_updated', 'network_updated', 'blockchain_updated', + 'new_transaction', 'status', 'verified', 'on_history', + 'channel', 'channels_updated', 'payment_failed', + 'payment_succeeded', 'invoice_status', 'request_status'] + # To avoid leaking references to "self" that prevent the + # window from being GC-ed when closed, callbacks should be + # methods of this class only, and specifically not be + # partials, lambdas or methods of subobjects. Hence... + register_callback(self.on_network, interests) - _logger = get_logger(__name__) + @pyqtProperty(bool, notify=isUptodateChanged) + def isUptodate(self): + return self.wallet.is_up_to_date() - dataChanged = pyqtSignal() # dummy to silence warnings + def on_network(self, event, *args): + # Handle in GUI thread (_network_signal -> on_network_qt) + self._network_signal.emit(event, args) + + def on_network_qt(self, event, args=None): + # note: we get events from all wallets! args are heterogenous so we can't + # shortcut here + if event == 'status': + self.isUptodateChanged.emit() + elif event == 'request_status': + self._logger.info(str(args)) + self.requestStatus.emit() + elif event == 'new_transaction': + wallet, tx = args + if wallet == self.wallet: + self.add_tx_notification(tx) + self._historyModel.init_model() + else: + self._logger.debug('unhandled event: %s %s' % (event, str(args))) - requestCreateSuccess = pyqtSignal() - requestCreateError = pyqtSignal([str,str], arguments=['code','error']) - requestStatus = pyqtSignal() - def on_request_status(self, event, *args): - self._logger.debug(str(event)) - self.requestStatus.emit() + def add_tx_notification(self, tx): + self._logger.debug('new transaction event') + self.tx_notification_queue.put(tx) + if not self.notification_timer.isActive(): + self._logger.debug('starting wallet notification timer') + self.notification_timer.start() + + def notify_transactions(self): + if self.tx_notification_queue.qsize() == 0: + self._logger.debug('queue empty, stopping wallet notification timer') + self.notification_timer.stop() + return + if not self.wallet.up_to_date: + return # no notifications while syncing + now = time.time() + rate_limit = 20 # seconds + if self.tx_notification_last_time + rate_limit > now: + return + self.tx_notification_last_time = now + self._logger.info("Notifying app about new transactions") + txns = [] + while True: + try: + txns.append(self.tx_notification_queue.get_nowait()) + except queue.Empty: + break + + from .qeapp import ElectrumQmlApplication + config = ElectrumQmlApplication._config + # Combine the transactions if there are at least three + if len(txns) >= 3: + total_amount = 0 + for tx in txns: + tx_wallet_delta = self.wallet.get_wallet_delta(tx) + if not tx_wallet_delta.is_relevant: + continue + total_amount += tx_wallet_delta.delta + self.userNotify.emit(self.wallet, _("{} new transactions: Total amount received in the new transactions {}").format(len(txns), config.format_amount_and_units(total_amount))) + else: + for tx in txns: + tx_wallet_delta = self.wallet.get_wallet_delta(tx) + if not tx_wallet_delta.is_relevant: + continue + self.userNotify.emit(self.wallet, + _("New transaction: {}").format(config.format_amount_and_units(tx_wallet_delta.delta))) historyModelChanged = pyqtSignal() @pyqtProperty(QETransactionListModel, notify=historyModelChanged) @@ -105,16 +198,6 @@ class QEWallet(QObject): return c+x - def on_status(self, status): - self._logger.info('wallet: status update: ' + str(status)) - self.isUptodateChanged.emit() - - # lightning feature? - isUptodateChanged = pyqtSignal() - @pyqtProperty(bool, notify=isUptodateChanged) - def isUptodate(self): - return self.wallet.is_up_to_date() - @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): self._logger.info('send_onchain: ' + address + ' ' + str(amount)) From 64745ece10fb36df1a89dd2cbf84d0e5aa0cc42d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 21:08:48 +0200 Subject: [PATCH 086/218] add simple internal notification popup, refactor MessageDialog --- electrum/gui/qml/components/MessageDialog.qml | 61 +++++++++++++++++++ .../gui/qml/components/NotificationPopup.qml | 57 +++++++++++++++++ electrum/gui/qml/components/main.qml | 61 ++----------------- 3 files changed, 124 insertions(+), 55 deletions(-) create mode 100644 electrum/gui/qml/components/MessageDialog.qml create mode 100644 electrum/gui/qml/components/NotificationPopup.qml diff --git a/electrum/gui/qml/components/MessageDialog.qml b/electrum/gui/qml/components/MessageDialog.qml new file mode 100644 index 000000000..cf8d86772 --- /dev/null +++ b/electrum/gui/qml/components/MessageDialog.qml @@ -0,0 +1,61 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +Dialog { + id: dialog + title: qsTr("Message") + + property bool yesno: false + property alias text: message.text + + signal yesClicked + signal noClicked + + parent: Overlay.overlay + modal: true + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + Overlay.modal: Rectangle { + color: "#aa000000" + } + + ColumnLayout { + TextArea { + id: message + Layout.preferredWidth: Overlay.overlay.width *2/3 + readOnly: true + wrapMode: TextInput.WordWrap + //textFormat: TextEdit.RichText // existing translations not richtext yet + background: Rectangle { + color: 'transparent' + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Button { + text: qsTr('Ok') + visible: !yesno + onClicked: dialog.close() + } + Button { + text: qsTr('Yes') + visible: yesno + onClicked: { + yesClicked() + dialog.close() + } + } + Button { + text: qsTr('No') + visible: yesno + onClicked: { + noClicked() + dialog.close() + } + } + } + } +} diff --git a/electrum/gui/qml/components/NotificationPopup.qml b/electrum/gui/qml/components/NotificationPopup.qml new file mode 100644 index 000000000..ed6595bd4 --- /dev/null +++ b/electrum/gui/qml/components/NotificationPopup.qml @@ -0,0 +1,57 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +Rectangle { + id: root + + property alias text: textItem.text + + property bool hide: true + + color: Qt.lighter(Material.background, 1.5) + radius: constants.paddingXLarge + + width: root.parent.width * 2/3 + height: layout.height + x: (root.parent.width - width) / 2 + y: -height + + states: [ + State { + name: 'expanded'; when: !hide + PropertyChanges { target: root; y: 100 } + } + ] + + transitions: [ + Transition { + from: ''; to: 'expanded'; reversible: true + NumberAnimation { properties: 'y'; duration: 300; easing.type: Easing.InOutQuad } + } + ] + + RowLayout { + id: layout + width: parent.width + Text { + id: textItem + Layout.alignment: Qt.AlignHCenter + font.pixelSize: constants.fontSizeLarge + color: Material.foreground + } + } + + Timer { + id: closetimer + interval: 5000 + repeat: false + onTriggered: hide = true + } + + Component.onCompleted: { + hide = false + closetimer.start() + } +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 71bbaf2bd..91679a1f6 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -169,62 +169,13 @@ ApplicationWindow property alias messageDialog: _messageDialog Component { id: _messageDialog - Dialog { - id: dialog - title: qsTr("Message") - - property bool yesno: false - property alias text: message.text - - signal yesClicked - signal noClicked - - parent: Overlay.overlay - modal: true - x: (parent.width - width) / 2 - y: (parent.height - height) / 2 - Overlay.modal: Rectangle { - color: "#aa000000" - } - - ColumnLayout { - TextArea { - id: message - Layout.preferredWidth: Overlay.overlay.width *2/3 - readOnly: true - wrapMode: TextInput.WordWrap - //textFormat: TextEdit.RichText // existing translations not richtext yet - background: Rectangle { - color: 'transparent' - } - } + MessageDialog {} + } - RowLayout { - Layout.alignment: Qt.AlignHCenter - Button { - text: qsTr('Ok') - visible: !yesno - onClicked: dialog.close() - } - Button { - text: qsTr('Yes') - visible: yesno - onClicked: { - yesClicked() - dialog.close() - } - } - Button { - text: qsTr('No') - visible: yesno - onClicked: { - noClicked() - dialog.close() - } - } - } - } - } + property alias notificationPopup: _notificationPopup + Component { + id: _notificationPopup + NotificationPopup {} } Component.onCompleted: { From 2229322bebc09979d9de9ade9ced35cd216221e3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 21:15:59 +0200 Subject: [PATCH 087/218] copy closebutton.png to gui/icons --- electrum/gui/icons/closebutton.png | Bin 0 -> 3521 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 electrum/gui/icons/closebutton.png diff --git a/electrum/gui/icons/closebutton.png b/electrum/gui/icons/closebutton.png new file mode 100644 index 0000000000000000000000000000000000000000..349241801c9d537ff2e54a57e08ee156c59f193c GIT binary patch literal 3521 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 Date: Wed, 30 Mar 2022 21:19:11 +0200 Subject: [PATCH 088/218] UI use constants --- electrum/gui/qml/components/Constants.qml | 2 ++ electrum/gui/qml/components/RequestDialog.qml | 5 +++-- .../gui/qml/components/wizard/WCHaveSeed.qml | 6 +++--- electrum/gui/qml/components/wizard/Wizard.qml | 20 +++++++++---------- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index 1ea8452cb..cbef3f31b 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -19,4 +19,6 @@ QtObject { readonly property int iconSizeSmall: 16 readonly property int iconSizeMedium: 24 readonly property int iconSizeLarge: 32 + readonly property int iconSizeXLarge: 48 + readonly property int iconSizeXXLarge: 64 } diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 0ae02cd1b..8a4bbc1a0 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -46,7 +46,7 @@ Dialog { } GridLayout { width: parent.width - rowSpacing: constants.paddingMedium + rowSpacing: constants.paddingXLarge columns: 3 Rectangle { @@ -62,7 +62,7 @@ Dialog { source: 'image://qrgen/' + modelItem.address Rectangle { - property int size: 58 + property int size: 57 // should be qr pixel multiple color: 'white' x: (parent.width - size) / 2 y: (parent.height - size) / 2 @@ -75,6 +75,7 @@ Dialog { y: 1 width: parent.width - 2 height: parent.height - 2 + scale: 0.9 } } } diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml index b305bc173..da0440d5d 100644 --- a/electrum/gui/qml/components/wizard/WCHaveSeed.qml +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -98,10 +98,10 @@ WizardComponent { id: contentText anchors.right: parent.right anchors.bottom: parent.bottom - leftPadding: text != '' ? 16 : 0 - rightPadding: text != '' ? 16 : 0 + leftPadding: text != '' ? constants.paddingLarge : 0 + rightPadding: text != '' ? constants.paddingLarge : 0 font.bold: false - font.pixelSize: 13 + font.pixelSize: constants.fontSizeSmall } } TextArea { diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml index 4ce028ddf..2978ed6cf 100644 --- a/electrum/gui/qml/components/wizard/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -135,28 +135,28 @@ Dialog { Image { source: "../../../icons/electrum.png" - Layout.preferredWidth: 48 - Layout.preferredHeight: 48 - Layout.leftMargin: 12 - Layout.topMargin: 12 - Layout.bottomMargin: 12 + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + Layout.leftMargin: constants.paddingMedium + Layout.topMargin: constants.paddingMedium + Layout.bottomMargin: constants.paddingMedium } Label { text: title elide: Label.ElideRight Layout.fillWidth: true - topPadding: 24 - bottomPadding: 24 + topPadding: constants.paddingXLarge + bottomPadding: constants.paddingXLarge font.bold: true - font.pixelSize: 16 + font.pixelSize: constants.fontSizeMedium } Rectangle { Layout.columnSpan: 2 Layout.fillWidth: true - Layout.leftMargin: 4 - Layout.rightMargin: 4 + Layout.leftMargin: constants.paddingTiny + Layout.rightMargin: constants.paddingTiny height: 1 color: Qt.rgba(0,0,0,0.5) } From 3a8e787b5839745fb646c4c6e0d697a20650b94c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 31 Mar 2022 14:19:03 +0200 Subject: [PATCH 089/218] qenetwork: add height and fiat changed signals --- electrum/gui/qml/qenetwork.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index 0f9ea33bc..18eeb95f1 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -15,62 +15,64 @@ class QENetwork(QObject): register_callback(self.on_proxy_set, ['proxy_set']) register_callback(self.on_status, ['status']) register_callback(self.on_fee_histogram, ['fee_histogram']) + register_callback(self.on_fiat, ['on_quotes','on_history']) _logger = get_logger(__name__) networkUpdated = pyqtSignal() blockchainUpdated = pyqtSignal() + heightChanged = pyqtSignal([int], arguments=['height']) defaultServerChanged = pyqtSignal() proxySet = pyqtSignal() proxyChanged = pyqtSignal() statusChanged = pyqtSignal() feeHistogramUpdated = pyqtSignal() + fiatUpdated = pyqtSignal() - dataChanged = pyqtSignal() # dummy to silence warnings + # shared signal for static properties + dataChanged = pyqtSignal() - _num_updates = 0 - _server = "" _height = 0 _status = "" def on_network_updated(self, event, *args): - self._num_updates = self._num_updates + 1 self.networkUpdated.emit() def on_blockchain_updated(self, event, *args): - self._logger.info('chainupdate: ' + str(event) + str(args)) - self._height = self.network.get_local_height() + if self._height != self.network.get_local_height(): + self._height = self.network.get_local_height() + self._logger.debug('new height: %d' % self._height) + self.heightChanged.emit(self._height) self.blockchainUpdated.emit() def on_default_server_changed(self, event, *args): - netparams = self.network.get_parameters() - self._server = str(netparams.server) self.defaultServerChanged.emit() def on_proxy_set(self, event, *args): - self._logger.info('proxy set') + self._logger.debug('proxy set') self.proxySet.emit() def on_status(self, event, *args): self._logger.debug('status updated: %s' % self.network.connection_status) - self._status = self.network.connection_status - self.statusChanged.emit() + if self._status != self.network.connection_status: + self._status = self.network.connection_status + self.statusChanged.emit() def on_fee_histogram(self, event, *args): self._logger.debug('fee histogram updated') self.feeHistogramUpdated.emit() - @pyqtProperty(int,notify=networkUpdated) - def updates(self): - return self._num_updates + def on_fiat(self, event, *args): + self._logger.debug('new fiat quotes') + self.fiatUpdated.emit() - @pyqtProperty(int,notify=blockchainUpdated) + @pyqtProperty(int,notify=heightChanged) def height(self): return self._height @pyqtProperty('QString',notify=defaultServerChanged) def server(self): - return self._server + return str(self.network.get_parameters().server) @server.setter def server(self, server): From 3b66cf70ee8dc1a8bfea773027989a425a98a9bc Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 31 Mar 2022 14:22:56 +0200 Subject: [PATCH 090/218] qewallet: minimally viable send_onchain() --- electrum/gui/qml/components/Send.qml | 11 +++---- electrum/gui/qml/qewallet.py | 48 +++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 51b993386..72e86e5c1 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -71,14 +71,13 @@ Pane { Button { text: qsTr('Pay') - enabled: false // TODO proper validation + enabled: amount.text != '' && address.text != ''// TODO proper validation onClicked: { - var i_amount = parseInt(amount.text) - if (isNaN(i_amount)) + var f_amount = parseFloat(amount.text) + if (isNaN(f_amount)) return - var result = Daemon.currentWallet.send_onchain(address.text, i_amount, undefined, false) - if (result) - app.stack.pop() + var sats = Config.unitsToSats(f_amount) + var result = Daemon.currentWallet.send_onchain(address.text, sats, undefined, false) } } diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 9ec0511c8..1ec8315dc 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -5,7 +5,7 @@ import time from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer from electrum.i18n import _ -from electrum.util import register_callback, Satoshis, format_time +from electrum.util import register_callback, Satoshis, format_time, parse_max_spend from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum import bitcoin @@ -200,15 +200,55 @@ class QEWallet(QObject): @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): - self._logger.info('send_onchain: ' + address + ' ' + str(amount)) + self._logger.info('send_onchain: %s %d' % (address,amount)) coins = self.wallet.get_spendable_coins(None) if not bitcoin.is_address(address): self._logger.warning('Invalid Bitcoin Address: ' + address) return False outputs = [PartialTxOutput.from_address_and_value(address, amount)] - tx = self.wallet.make_unsigned_transaction(coins=coins,outputs=outputs) - return True + self._logger.info(str(outputs)) + output_values = [x.value for x in outputs] + if any(parse_max_spend(outval) for outval in output_values): + output_value = '!' + else: + output_value = sum(output_values) + self._logger.info(str(output_value)) + # see qt/confirm_tx_dialog qt/main_window + tx = self.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None) + self._logger.info(str(tx.to_json())) + + if len(tx.to_json()['outputs']) < 2: + self._logger.info('no change output??? : %s' % str(tx.to_json()['outputs'])) + return + + from .qeapp import ElectrumQmlApplication + self.config = ElectrumQmlApplication._config + + use_rbf = bool(self.config.get('use_rbf', True)) + tx.set_rbf(use_rbf) + + def cb(result): + self._logger.info('signing was succesful? %s' % str(result)) + tx = self.wallet.sign_transaction(tx, None) + if not tx.is_complete(): + self._logger.info('tx not complete') + return + + self.network = ElectrumQmlApplication._daemon.network + + try: + self._logger.info('running broadcast in thread') + self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) + self._logger.info('broadcast submit done') + except TxBroadcastError as e: + self._logger.info(e) + return + except BestEffortRequestFailed as e: + self._logger.info(e) + return + + return def create_bitcoin_request(self, amount: int, message: str, expiration: int, ignore_gap: bool) -> Optional[str]: addr = self.wallet.get_unused_address() From cb203dfe506f5d8784d80bf7b640b9fab2e39654 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 31 Mar 2022 14:29:25 +0200 Subject: [PATCH 091/218] show popup for user notifications --- electrum/gui/qml/components/main.qml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 91679a1f6..79fa14443 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -25,6 +25,7 @@ ApplicationWindow header: ToolBar { id: toolbar + RowLayout { anchors.fill: parent @@ -81,7 +82,7 @@ ApplicationWindow Image { Layout.preferredWidth: constants.iconSizeSmall Layout.preferredHeight: constants.iconSizeSmall - visible: Daemon.currentWallet.isWatchOnly + visible: Daemon.currentWallet && Daemon.currentWallet.isWatchOnly source: '../../icons/eye1.png' scale: 1.5 } @@ -212,4 +213,11 @@ ApplicationWindow dialog.open() } } + + Connections { + target: AppController + function onUserNotify(message) { + var item = app.notificationPopup.createObject(app, {'text': message}) + } + } } From d88cd75460c17804fc6eda1a2a661446273a0400 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 1 Apr 2022 16:17:50 +0200 Subject: [PATCH 092/218] keep all models and various UI items updated on new transactions --- .../gui/qml/components/BalanceSummary.qml | 9 ++++ electrum/gui/qml/components/History.qml | 6 +++ electrum/gui/qml/components/Receive.qml | 7 ++++ electrum/gui/qml/components/RequestDialog.qml | 24 ++++++++--- electrum/gui/qml/qerequestlistmodel.py | 13 ++++++ electrum/gui/qml/qetransactionlistmodel.py | 30 ++++++++++++- electrum/gui/qml/qewallet.py | 42 ++++++++++++------- 7 files changed, 110 insertions(+), 21 deletions(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index 24c664300..ec8e8c320 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -45,6 +45,8 @@ Frame { } } + // instead of all these explicit connections, we should expose + // formatted balances directly as a property Connections { target: Config function onBaseUnitChanged() { setBalances() } @@ -56,5 +58,12 @@ Frame { function onWalletLoaded() { setBalances() } } + Connections { + target: Daemon.currentWallet + function onBalanceChanged() { + setBalances() + } + } + Component.onCompleted: setBalances() } diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 8239a0275..e51c3a5c5 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -133,4 +133,10 @@ Pane { } + Connections { + target: Network + function onHeightChanged(height) { + Daemon.currentWallet.historyModel.updateBlockchainHeight(height) + } + } } diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index b405cebd9..92a663732 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -347,4 +347,11 @@ Pane { } } + Connections { + target: Daemon.currentWallet + function onRequestStatusChanged(key, status) { + Daemon.currentWallet.requestModel.updateRequest(key, status) + } + } + } diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 8a4bbc1a0..cb9bdf844 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -13,11 +13,10 @@ Dialog { parent: Overlay.overlay modal: true + standardButtons: Dialog.Ok - width: parent.width - constants.paddingXLarge - height: parent.height - constants.paddingXLarge - x: (parent.width - width) / 2 - y: (parent.height - height) / 2 + width: parent.width + height: parent.height Overlay.modal: Rectangle { color: "#aa000000" @@ -42,6 +41,7 @@ Dialog { icon.width: 32 icon.height: 32 onClicked: dialog.close() + visible: false } } GridLayout { @@ -122,13 +122,19 @@ Dialog { text: qsTr('Address') } Label { - Layout.columnSpan: 2 Layout.fillWidth: true font.family: FixedFont font.pixelSize: constants.fontSizeLarge wrapMode: Text.WrapAnywhere text: modelItem.address } + ToolButton { + icon.source: '../../icons/copy.png' + icon.color: 'transparent' + onClicked: { + AppController.textToClipboard(modelItem.address) + } + } Label { text: qsTr('Status') @@ -162,4 +168,12 @@ Dialog { } } + Connections { + target: Daemon.currentWallet + function onRequestStatusChanged(key, code) { + if (key != modelItem.key) + return + modelItem = Daemon.currentWallet.get_request(key) + } + } } diff --git a/electrum/gui/qml/qerequestlistmodel.py b/electrum/gui/qml/qerequestlistmodel.py index f0cb65fb6..430f8a132 100644 --- a/electrum/gui/qml/qerequestlistmodel.py +++ b/electrum/gui/qml/qerequestlistmodel.py @@ -17,6 +17,7 @@ class QERequestListModel(QAbstractListModel): _ROLE_NAMES=('key','type','timestamp','message','amount','status','address') _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])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) def rowCount(self, index): return len(self.requests) @@ -87,3 +88,15 @@ class QERequestListModel(QAbstractListModel): self.endRemoveRows() break i = i + 1 + + @pyqtSlot(str, int) + def updateRequest(self, key, status): + self._logger.debug('updating request for %s to %d' % (key,status)) + i = 0 + for item in self.requests: + if item['key'] == key: + req = self.wallet.get_request(key) + item['status'] = req.get_status_str(status) + index = self.index(i,0) + self.dataChanged.emit(index, index, [self._ROLE_RMAP['status']]) + i = i + 1 diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 88a44627d..224824955 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -1,8 +1,10 @@ +from datetime import datetime + 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 +from electrum.util import Satoshis, TxMinedInfo class QETransactionListModel(QAbstractListModel): def __init__(self, wallet, parent=None): @@ -18,6 +20,7 @@ class QETransactionListModel(QAbstractListModel): 'inputs','outputs') _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])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) def rowCount(self, index): return len(self.tx_history) @@ -55,3 +58,28 @@ class QETransactionListModel(QAbstractListModel): self.tx_history.reverse() self.endInsertRows() + def update_tx(self, txid, info): + i = 0 + for tx in self.tx_history: + if tx['txid'] == txid: + tx['height'] = info.height + tx['confirmations'] = info.conf + tx['timestamp'] = info.timestamp + tx['date'] = datetime.fromtimestamp(info.timestamp) + index = self.index(i,0) + roles = [self._ROLE_RMAP[x] for x in ['height','confirmations','timestamp','date']] + self.dataChanged.emit(index, index, roles) + return + i = i + 1 + + @pyqtSlot(int) + def updateBlockchainHeight(self, height): + self._logger.debug('updating height to %d' % height) + i = 0 + for tx in self.tx_history: + if tx['height'] > 0: + tx['confirmations'] = height - tx['height'] + 1 + index = self.index(i,0) + roles = [self._ROLE_RMAP['confirmations']] + self.dataChanged.emit(index, index, roles) + i = i + 1 diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 1ec8315dc..1b25d2802 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -28,7 +28,7 @@ class QEWallet(QObject): dataChanged = pyqtSignal() isUptodateChanged = pyqtSignal() - requestStatus = pyqtSignal() + requestStatusChanged = pyqtSignal([str,int], arguments=['key','status']) requestCreateSuccess = pyqtSignal() requestCreateError = pyqtSignal([str,str], arguments=['code','error']) @@ -37,6 +37,7 @@ class QEWallet(QObject): def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet + self._historyModel = QETransactionListModel(wallet) self._addressModel = QEAddressListModel(wallet) self._requestModel = QERequestListModel(wallet) @@ -53,10 +54,9 @@ class QEWallet(QObject): self.notification_timer.timeout.connect(self.notify_transactions) self._network_signal.connect(self.on_network_qt) - interests = ['wallet_updated', 'network_updated', 'blockchain_updated', - 'new_transaction', 'status', 'verified', 'on_history', - 'channel', 'channels_updated', 'payment_failed', - 'payment_succeeded', 'invoice_status', 'request_status'] + interests = ['wallet_updated', 'new_transaction', 'status', 'verified', + 'on_history', 'channel', 'channels_updated', 'payment_failed', + 'payment_succeeded', 'invoice_status', 'request_status'] # To avoid leaking references to "self" that prevent the # window from being GC-ed when closed, callbacks should be # methods of this class only, and specifically not be @@ -77,13 +77,24 @@ class QEWallet(QObject): if event == 'status': self.isUptodateChanged.emit() elif event == 'request_status': - self._logger.info(str(args)) - self.requestStatus.emit() + wallet, addr, c = args + if wallet == self.wallet: + self._logger.debug('request status %d for address %s' % (c, addr)) + self.requestStatusChanged.emit(addr, c) elif event == 'new_transaction': wallet, tx = args if wallet == self.wallet: self.add_tx_notification(tx) - self._historyModel.init_model() + self._historyModel.init_model() # TODO: be less dramatic + elif event == 'verified': + wallet, txid, info = args + if wallet == self.wallet: + self._historyModel.update_tx(txid, info) + elif event == 'wallet_updated': + wallet, = args + if wallet == self.wallet: + self._logger.debug('wallet %s updated' % str(wallet)) + self.balanceChanged.emit() else: self._logger.debug('unhandled event: %s %s' % (event, str(args))) @@ -115,8 +126,7 @@ class QEWallet(QObject): except queue.Empty: break - from .qeapp import ElectrumQmlApplication - config = ElectrumQmlApplication._config + config = self.wallet.config # Combine the transactions if there are at least three if len(txns) >= 3: total_amount = 0 @@ -222,10 +232,7 @@ class QEWallet(QObject): self._logger.info('no change output??? : %s' % str(tx.to_json()['outputs'])) return - from .qeapp import ElectrumQmlApplication - self.config = ElectrumQmlApplication._config - - use_rbf = bool(self.config.get('use_rbf', True)) + use_rbf = bool(self.wallet.config.get('use_rbf', True)) tx.set_rbf(use_rbf) def cb(result): @@ -235,7 +242,7 @@ class QEWallet(QObject): self._logger.info('tx not complete') return - self.network = ElectrumQmlApplication._daemon.network + self.network = self.wallet.network # TODO not always defined? try: self._logger.info('running broadcast in thread') @@ -318,3 +325,8 @@ class QEWallet(QObject): def delete_request(self, key: str): self.wallet.delete_request(key) self._requestModel.delete_request(key) + + @pyqtSlot('QString', result='QVariant') + def get_request(self, key: str): + req = self.wallet.get_request(key) + return self._requestModel.request_to_model(req) From ddbd785a469963bd7020dc64253a6a209cd230d5 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 1 Apr 2022 16:19:04 +0200 Subject: [PATCH 093/218] add version information to QML context --- electrum/gui/qml/__init__.py | 2 -- electrum/gui/qml/qeapp.py | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index b8ed7f2d8..818ed39c6 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -63,8 +63,6 @@ class ElectrumGui(Logger): os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" self.gui_thread = threading.current_thread() - #self.config = config - #self.daemon = daemon self.plugins = plugins self.app = ElectrumQmlApplication(sys.argv, config, daemon) # timer diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 83dccbfe2..00c95bb62 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -7,6 +7,7 @@ from PyQt5.QtGui import QGuiApplication, QFontDatabase from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine #, QQmlComponent from electrum.logging import Logger, get_logger +from electrum import version from .qeconfig import QEConfig from .qedaemon import QEDaemon, QEWalletListModel @@ -82,7 +83,7 @@ class ElectrumQmlApplication(QGuiApplication): self.logger = get_logger(__name__) - ElectrumQmlApplication._config = config + #ElectrumQmlApplication._config = config ElectrumQmlApplication._daemon = daemon qmlRegisterType(QEWalletListModel, 'org.electrum', 1, 0, 'WalletListModel') @@ -114,6 +115,11 @@ class ElectrumQmlApplication(QGuiApplication): self.context.setContextProperty('Network', self._qenetwork) self.context.setContextProperty('Daemon', self._qedaemon) self.context.setContextProperty('FixedFont', self.fixedFont) + self.context.setContextProperty('BUILD', { + 'electrum_version': version.ELECTRUM_VERSION, + 'apk_version': version.APK_VERSION, + 'protocol_version': version.PROTOCOL_VERSION + }) qInstallMessageHandler(self.message_handler) From 201669d1781a933c06a8b458f0dadba4ddd017b9 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 1 Apr 2022 16:20:14 +0200 Subject: [PATCH 094/218] UI here and there --- electrum/gui/qml/components/Addresses.qml | 6 ++++-- electrum/gui/qml/components/Receive.qml | 10 +--------- electrum/gui/qml/components/main.qml | 2 +- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index 4c11bc0b0..f8aeaa977 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -82,8 +82,10 @@ Pane { Layout.preferredHeight: constants.iconSizeMedium color: model.held ? Qt.rgba(1,0.93,0,0.75) - : model.numtx > 0 && model.balance == 0 - ? Qt.rgba(0.75,0.75,0.75,1) + : model.numtx > 0 + ? model.balance == 0 + ? Qt.rgba(0.5,0.5,0.5,1) + : Qt.rgba(0.75,0.75,0.75,1) : model.type == 'receive' ? Qt.rgba(0,1,0,0.5) : Qt.rgba(1,0.93,0,0.75) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 92a663732..db564846d 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -94,6 +94,7 @@ Pane { RowLayout { Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter + visible: false CheckBox { id: cb_onchain text: qsTr('Onchain') @@ -282,15 +283,6 @@ Pane { } - 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 } - NumberAnimation { properties: 'opacity'; to: 1.0; duration: 700 * (1-from) } - } - remove: Transition { NumberAnimation { properties: 'scale'; to: 0; duration: 400 } NumberAnimation { properties: 'opacity'; to: 0; duration: 300 } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 79fa14443..88833ba5b 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -106,7 +106,7 @@ ApplicationWindow ToolButton { id: menuButton - enabled: stack.currentItem.menu !== undefined && stack.currentItem.menu.count > 0 + enabled: stack.currentItem && stack.currentItem.menu ? stack.currentItem.menu.count > 0 : false text: enabled ? qsTr("≡") : '' font.pixelSize: constants.fontSizeXLarge onClicked: { From a65ea46b5db30c1cfb87126ab49d6dce7ff78b00 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Apr 2022 12:02:27 +0200 Subject: [PATCH 095/218] avoid duplicate QEWallet instances --- electrum/gui/qml/qedaemon.py | 7 ++++++- electrum/gui/qml/qewallet.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index d31f16bb2..ebca6082b 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -43,6 +43,11 @@ class QEWalletListModel(QAbstractListModel): def add_wallet(self, wallet_path = None, wallet: Abstract_Wallet = None): if wallet_path == None and wallet == None: return + # only add wallet instance if instance not yet in model + if wallet: + for name,path,w in self.wallets: + if w == wallet: + return self.beginInsertRows(QModelIndex(), len(self.wallets), len(self.wallets)); if wallet == None: wallet_name = os.path.basename(wallet_path) @@ -117,7 +122,7 @@ class QEDaemon(QObject): wallet = self.daemon.load_wallet(self._path, password) if wallet != None: self._loaded_wallets.add_wallet(wallet=wallet) - self._current_wallet = QEWallet(wallet) + self._current_wallet = QEWallet.getInstanceFor(wallet) self.walletLoaded.emit() self.daemon.config.save_last_wallet(wallet) else: diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 1b25d2802..0604cbf43 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -18,6 +18,19 @@ from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel class QEWallet(QObject): + __instances = [] + + # this factory method should be used to instantiate QEWallet + # so we have only one QEWallet for each electrum.wallet + @classmethod + def getInstanceFor(cls, wallet): + for i in cls.__instances: + if i.wallet == wallet: + return i + i = QEWallet(wallet) + cls.__instances.append(i) + return i + _logger = get_logger(__name__) # emitted when wallet wants to display a user notification From 50e7c082cd1c3946f34350c1ab10e75cbb69aeff Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Apr 2022 12:04:32 +0200 Subject: [PATCH 096/218] request dialog improve, icons --- electrum/gui/icons/copy_bw.png | Bin 0 -> 880 bytes electrum/gui/icons/delete.png | Bin 0 -> 453 bytes electrum/gui/icons/share.png | Bin 0 -> 3325 bytes electrum/gui/qml/components/RequestDialog.qml | 233 +++++++++--------- 4 files changed, 119 insertions(+), 114 deletions(-) create mode 100644 electrum/gui/icons/copy_bw.png create mode 100644 electrum/gui/icons/delete.png create mode 100644 electrum/gui/icons/share.png diff --git a/electrum/gui/icons/copy_bw.png b/electrum/gui/icons/copy_bw.png new file mode 100644 index 0000000000000000000000000000000000000000..739edbecb2f70ec848b3291ca5f30e0d044b8b0a GIT binary patch literal 880 zcmeAS@N?(olHy`uVBq!ia0vp^&w%(c2OE%_dN+S5kYY>nc6VX;4}uH!E}sk(;Vkfo zEM{Qf76xHPhFNnYfP(BLp1!W^kJ)%Q#8`StRz6~2VEXIn;uunK>+M}bBUML+;}8Gm z>~%>oHgfSg!n>ofh^g-Z+qwh1CsN*UPLqC<=G6CqMS0?)E$hthe%`mCFJH!o z_M68V_dJ$Zpu3-kkwaKO4H+x=2&lpEg-J(Q=2y;qa^OPwTbq;ihYi*ne4i{M^=sW) zX(sPTkUEDM$hg74afU;urd*OdSO0HeYpE=2|C`J&n-|WGpRx4)-`(Ctk0*teMoBCy zH;+2iWWbonbciL9X=&B3T}Sr6c)i=})9wd5#GTFe8yr`aKYm<~w{fWs%q1IA_UWz6 zw>~*@<<+gvRj#iVdv<5P-c7Hf#JuviiLVTFQxDq0T@BF#ahXAQ+4L>Nb)KIxyu!B5 zOxb>XXIi{lt^vvq55alEF>^IR}DUI1jNPFo$ppCj#RRFXXTQ zu|eU1z>J9?Kf^E?7Ou!JJebbcdVH?R&CHZL^CsO1v)g~LBqwbh?`nx>5fyhn{`Tj7 zXU|z2^Jzywy^s2Sv1ywg$YG7%8LNSjwBPg7jh{PgYd=}<_P&)^e$qVVe2vkK*hf(j zr<$svF^h0L$R~~bIy{c0ORa^kO_Gt^x9Z!p8s65M8!pY|l=l25wQt?}g-dmI9EXM* zBD_Fh_oJ!(#_MX||L@ZxOaCwb$J|!<$*FcTRXisF(YC+Gw(YN}kD73QzW9>w&sksot#qt{a8thbi+_0l zvyUP~mYTbQw@t|CH z*Y@`bzfW!7Rler;DmIhSH7ngr>`PZ%UemT^Psx(go7lGYap=94x@PoY(c?{QX7k_u zU3#}{v()XZ|1b1kZ;ZeCLu~fppTFMtf7>&Uf41vhZLn`uUb0I3^z}I2cc}>EdQVqB Jmvv4FO#loM%#i>9 literal 0 HcmV?d00001 diff --git a/electrum/gui/icons/share.png b/electrum/gui/icons/share.png new file mode 100644 index 0000000000000000000000000000000000000000..d0dc761d4544fc091d2e59a22109fc60ffaa0a61 GIT binary patch literal 3325 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 0 - text: qsTr('Amount') - } - Label { - visible: modelItem.amount > 0 - text: Config.formatSats(modelItem.amount, false) - font.family: FixedFont - font.pixelSize: constants.fontSizeLarge - } - Label { - visible: modelItem.amount > 0 - Layout.fillWidth: true - text: Config.baseUnit - color: Material.accentColor - font.pixelSize: constants.fontSizeLarge - } + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 3 + color: Material.accentColor + } - Label { - text: qsTr('Address') - } - Label { - Layout.fillWidth: true - font.family: FixedFont - font.pixelSize: constants.fontSizeLarge - wrapMode: Text.WrapAnywhere - text: modelItem.address - } - ToolButton { - icon.source: '../../icons/copy.png' - icon.color: 'transparent' - onClicked: { - AppController.textToClipboard(modelItem.address) + RowLayout { + Layout.columnSpan: 3 + Layout.alignment: Qt.AlignHCenter + Button { + icon.source: '../../icons/delete.png' + text: qsTr('Delete') + onClicked: { + Daemon.currentWallet.delete_request(modelItem.key) + dialog.close() + } + } + Button { + icon.source: '../../icons/copy_bw.png' + icon.color: 'transparent' + text: 'Copy' + enabled: false + } + Button { + icon.source: '../../icons/share.png' + text: 'Share' + enabled: false + } + } + Label { + visible: modelItem.message != '' + text: qsTr('Description') + } + Label { + visible: modelItem.message != '' + Layout.columnSpan: 2 + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: modelItem.message + font.pixelSize: constants.fontSizeLarge } - } - Label { - text: qsTr('Status') - } - Label { - Layout.columnSpan: 2 - Layout.fillWidth: true - font.pixelSize: constants.fontSizeLarge - text: modelItem.status - } + Label { + visible: modelItem.amount > 0 + text: qsTr('Amount') + } + Label { + visible: modelItem.amount > 0 + text: Config.formatSats(modelItem.amount, false) + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + } + Label { + visible: modelItem.amount > 0 + Layout.fillWidth: true + text: Config.baseUnit + color: Material.accentColor + font.pixelSize: constants.fontSizeLarge + } - RowLayout { - Layout.columnSpan: 3 - Layout.fillHeight: true - Layout.alignment: Qt.AlignHCenter - Button { - text: 'Delete' + Label { + text: qsTr('Address') + } + Label { + Layout.fillWidth: true + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + wrapMode: Text.WrapAnywhere + text: modelItem.address + } + ToolButton { + icon.source: '../../icons/copy_bw.png' onClicked: { - Daemon.currentWallet.delete_request(modelItem.key) - dialog.close() + AppController.textToClipboard(modelItem.address) } } - Button { - text: 'Copy' - enabled: false + + Label { + text: qsTr('Status') } - Button { - text: 'Share' - enabled: false + Label { + Layout.columnSpan: 2 + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + text: modelItem.status } + } } From 5d77daa5e350704e4b18a5fc74d40b59e9da8d49 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Apr 2022 13:21:05 +0200 Subject: [PATCH 097/218] add currencies to preferences --- electrum/gui/qml/components/Preferences.qml | 11 +++++++++++ electrum/gui/qml/qeconfig.py | 9 +++++++++ electrum/gui/qml/qedaemon.py | 5 +++++ 3 files changed, 25 insertions(+) diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 98f560302..461612e52 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -56,6 +56,15 @@ Pane { text: qsTr('Write logs to file') enabled: false } + + Label { + text: qsTr('Fiat Currency') + } + + ComboBox { + id: currencies + model: Daemon.currencies + } } } @@ -73,11 +82,13 @@ Pane { function save() { Config.baseUnit = baseUnit.currentValue Config.thousandsSeparator = thousands.checked + Config.fiatCurrency = currencies.currentValue ? currencies.currentValue : '' app.stack.pop() } Component.onCompleted: { baseUnit.currentIndex = ['BTC','mBTC','bits','sat'].indexOf(Config.baseUnit) thousands.checked = Config.thousandsSeparator + currencies.currentIndex = currencies.indexOfValue(Config.fiatCurrency) } } diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index ebf736e1e..16205359a 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -68,6 +68,15 @@ class QEConfig(QObject): self.config.amt_add_thousands_sep = checked self.thousandsSeparatorChanged.emit() + fiatCurrencyChanged = pyqtSignal() + @pyqtProperty(str, notify=fiatCurrencyChanged) + def fiatCurrency(self): + return self.config.get('currency') + + @fiatCurrency.setter + def fiatCurrency(self, currency): + self.config.set_key('currency', currency) + self.fiatCurrencyChanged.emit() @pyqtSlot(int, result=str) @pyqtSlot(int, bool, result=str) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index ebca6082b..d2c57b71a 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -96,6 +96,7 @@ class QEDaemon(QObject): activeWalletsChanged = pyqtSignal() availableWalletsChanged = pyqtSignal() walletOpenError = pyqtSignal([str], arguments=["error"]) + currenciesChanged = pyqtSignal() @pyqtSlot() @pyqtSlot(str) @@ -150,3 +151,7 @@ class QEDaemon(QObject): self._available_wallets = QEAvailableWalletListModel(self.daemon) return self._available_wallets + + @pyqtProperty('QVariantList', notify=currenciesChanged) + def currencies(self): + return [''] + self.daemon.fx.get_currencies(False) From d5cfb67ebe7e22542143fa1dcc0815a4fe6ea99d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Apr 2022 17:18:04 +0200 Subject: [PATCH 098/218] add fiat<->sat conversion methods and hook up UI --- electrum/gui/qml/components/Receive.qml | 31 +++++++++++++- electrum/gui/qml/components/Send.qml | 54 +++++++++++++++++++++---- electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qedaemon.py | 28 +++++++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index db564846d..65cfb676f 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -81,16 +81,20 @@ Pane { TextField { id: amountFiat + visible: Config.fiatCurrency != '' font.family: FixedFont Layout.fillWidth: true inputMethodHints: Qt.ImhDigitsOnly } Label { - text: qsTr('EUR') + visible: Config.fiatCurrency != '' + text: Config.fiatCurrency color: Material.accentColor } + Item { visible: Config.fiatCurrency == ''; width: 1; height: 1; Layout.columnSpan: 2 } + RowLayout { Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter @@ -346,4 +350,29 @@ Pane { } } + Connections { + target: amount + function onTextChanged() { + if (amountFiat.activeFocus) + return + var a = Config.unitsToSats(amount.text) + amountFiat.text = Daemon.fiatValue(a) + } + } + Connections { + target: amountFiat + function onTextChanged() { + if (amountFiat.activeFocus) { + amount.text = Daemon.satoshiValue(amountFiat.text) + } + } + } + Connections { + target: Network + function onFiatUpdated() { + var a = Config.unitsToSats(amount.text) + amountFiat.text = Daemon.fiatValue(a) + } + } + } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 72e86e5c1..0da854df6 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -8,10 +8,10 @@ Pane { GridLayout { width: parent.width - columns: 4 + columns: 6 BalanceSummary { - Layout.columnSpan: 4 + Layout.columnSpan: 6 Layout.alignment: Qt.AlignHCenter } @@ -21,7 +21,7 @@ Pane { TextField { id: address - Layout.columnSpan: 2 + Layout.columnSpan: 4 Layout.fillWidth: true font.family: FixedFont placeholderText: qsTr('Paste address or invoice') @@ -46,11 +46,26 @@ Pane { } Label { - text: Config.baseUnit + text: Config.baseUnit + ' ' // add spaces for easy right margin color: Material.accentColor - Layout.fillWidth: true } + TextField { + id: amountFiat + visible: Config.fiatCurrency != '' + font.family: FixedFont + placeholderText: qsTr('Amount') + inputMethodHints: Qt.ImhPreferNumbers + } + + Label { + visible: Config.fiatCurrency != '' + text: Config.fiatCurrency + color: Material.accentColor + } + + Item { visible: Config.fiatCurrency == ''; height: 1; Layout.columnSpan: 2; Layout.fillWidth: true } + Item { width: 1; height: 1 } // workaround colspan on baseunit messing up row above Label { @@ -61,11 +76,11 @@ Pane { id: fee font.family: FixedFont placeholderText: qsTr('sat/vB') - Layout.columnSpan: 3 + Layout.columnSpan: 5 } RowLayout { - Layout.columnSpan: 4 + Layout.columnSpan: 6 Layout.alignment: Qt.AlignHCenter spacing: 10 @@ -94,6 +109,31 @@ Pane { } } + Connections { + target: amount + function onTextChanged() { + if (amountFiat.activeFocus) + return + var a = Config.unitsToSats(amount.text) + amountFiat.text = Daemon.fiatValue(a) + } + } + Connections { + target: amountFiat + function onTextChanged() { + if (amountFiat.activeFocus) { + amount.text = Daemon.satoshiValue(amountFiat.text) + } + } + } + Connections { + target: Network + function onFiatUpdated() { + var a = Config.unitsToSats(amount.text) + amountFiat.text = Daemon.fiatValue(a) + } + } + // make clicking the dialog background move the scope away from textedit fields // so the keyboard goes away MouseArea { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 00c95bb62..056f5f874 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -121,6 +121,8 @@ class ElectrumQmlApplication(QGuiApplication): 'protocol_version': version.PROTOCOL_VERSION }) + self._qeconfig.fiatCurrencyChanged.connect(self._qedaemon.setFiatCurrency) + qInstallMessageHandler(self.message_handler) # get notified whether root QML document loads or not diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index d2c57b71a..c2688c44f 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -1,4 +1,5 @@ import os +from decimal import Decimal from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex @@ -7,6 +8,7 @@ from electrum.util import register_callback, get_new_wallet_name, WalletFileExce from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import WalletStorage, StorageReadWriteError +from electrum.bitcoin import COIN from .qewallet import QEWallet @@ -133,6 +135,32 @@ class QEDaemon(QObject): self._logger.error(str(e)) self.walletOpenError.emit(str(e)) + @pyqtSlot(str, result=str) + def fiatValue(self, satoshis): + rate = self.daemon.fx.exchange_rate() + try: + sd = Decimal(satoshis) + if sd == 0: + return '' + except: + return '' + return self.daemon.fx.value_str(satoshis,rate) + + # TODO: move conversion to FxThread + @pyqtSlot(str, result=str) + def satoshiValue(self, fiat): + rate = self.daemon.fx.exchange_rate() + try: + fd = Decimal(fiat) + except: + return '' + v = fd / Decimal(rate) * COIN + return '' if v.is_nan() else self.daemon.config.format_amount(v) + + @pyqtSlot() + def setFiatCurrency(self): + self.daemon.fx.set_currency(self.daemon.config.get('currency')) + @pyqtProperty('QString') def path(self): return self._path From ff33102b917bce67a1b79effd09b4c73f6178a19 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Apr 2022 17:18:49 +0200 Subject: [PATCH 099/218] use qint64 for sats, not int, as it will overflow --- electrum/gui/qml/qeconfig.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 16205359a..ec9c27904 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -78,8 +78,8 @@ class QEConfig(QObject): self.config.set_key('currency', currency) self.fiatCurrencyChanged.emit() - @pyqtSlot(int, result=str) - @pyqtSlot(int, bool, result=str) + @pyqtSlot('qint64', result=str) + @pyqtSlot('qint64', bool, result=str) def formatSats(self, satoshis, with_unit=False): if with_unit: return self.config.format_amount_and_units(satoshis) @@ -93,7 +93,7 @@ class QEConfig(QObject): def max_precision(self): return self.decimal_point() + 0 #self.extra_precision - @pyqtSlot(str, result=int) + @pyqtSlot(str, result='qint64') def unitsToSats(self, unitAmount): # returns amt in satoshis try: From e30cb4ed5f295622b4616f08e1f40444ad4ddb2d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Apr 2022 17:19:25 +0200 Subject: [PATCH 100/218] android back button pops pages from stackview unless we reach bottom of stack --- electrum/gui/qml/components/main.qml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 88833ba5b..b175505ac 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -196,9 +196,14 @@ ApplicationWindow } onClosing: { - // destroy most GUI components so that we don't dump so many null reference warnings on exit - app.header.visible = false - mainStackView.clear() + if (stack.depth > 1) { + close.accepted = false + stack.pop() + } else { + // destroy most GUI components so that we don't dump so many null reference warnings on exit + app.header.visible = false + mainStackView.clear() + } } Connections { From d3e27373081fd5d6e9ef01f6f7badd6ef007b8b7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 5 Apr 2022 13:57:42 +0200 Subject: [PATCH 101/218] complete and refactor Fx preferences and use in Send/Receive tabs --- electrum/gui/qml/components/Preferences.qml | 65 ++++++++---- electrum/gui/qml/components/Receive.qml | 18 ++-- electrum/gui/qml/components/Send.qml | 18 ++-- electrum/gui/qml/qeapp.py | 5 +- electrum/gui/qml/qeconfig.py | 10 -- electrum/gui/qml/qedaemon.py | 37 ++----- electrum/gui/qml/qefx.py | 107 ++++++++++++++++++++ electrum/gui/qml/qenetwork.py | 6 -- 8 files changed, 177 insertions(+), 89 deletions(-) create mode 100644 electrum/gui/qml/qefx.py diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 461612e52..a9de2ff7e 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -35,12 +35,20 @@ Pane { ComboBox { id: baseUnit model: ['BTC','mBTC','bits','sat'] + onCurrentValueChanged: { + if (activeFocus) + Config.baseUnit = currentValue + } } CheckBox { id: thousands Layout.columnSpan: 2 text: qsTr('Add thousands separators to bitcoin amounts') + onCheckedChanged: { + if (activeFocus) + Config.thousandsSeparator = checked + } } CheckBox { @@ -50,45 +58,58 @@ Pane { enabled: false } + Label { + text: qsTr('Fiat Currency') + } + + ComboBox { + id: currencies + model: Daemon.fx.currencies + onCurrentValueChanged: { + if (activeFocus) + Daemon.fx.fiatCurrency = currentValue + } + } + CheckBox { - id: writeLogs + id: historyRates + text: qsTr('History rates') + enabled: currencies.currentValue != '' Layout.columnSpan: 2 - text: qsTr('Write logs to file') - enabled: false + onCheckStateChanged: { + if (activeFocus) + Daemon.fx.historyRates = checked + } } Label { - text: qsTr('Fiat Currency') + text: qsTr('Source') + enabled: currencies.currentValue != '' } ComboBox { - id: currencies - model: Daemon.currencies + id: rateSources + enabled: currencies.currentValue != '' + model: Daemon.fx.rateSources + onModelChanged: { + currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) + } + onCurrentValueChanged: { + if (activeFocus) + Daemon.fx.rateSource = currentValue + } } } } - RowLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - Button { - text: qsTr('Save') - onClicked: save() - } - } - } - - function save() { - Config.baseUnit = baseUnit.currentValue - Config.thousandsSeparator = thousands.checked - Config.fiatCurrency = currencies.currentValue ? currencies.currentValue : '' - app.stack.pop() } Component.onCompleted: { baseUnit.currentIndex = ['BTC','mBTC','bits','sat'].indexOf(Config.baseUnit) thousands.checked = Config.thousandsSeparator - currencies.currentIndex = currencies.indexOfValue(Config.fiatCurrency) + currencies.currentIndex = currencies.indexOfValue(Daemon.fx.fiatCurrency) + historyRates.checked = Daemon.fx.historyRates + rateSources.currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) } } diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 65cfb676f..3a5aa2ff1 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -81,19 +81,19 @@ Pane { TextField { id: amountFiat - visible: Config.fiatCurrency != '' + visible: Daemon.fx.fiatCurrency != '' font.family: FixedFont Layout.fillWidth: true inputMethodHints: Qt.ImhDigitsOnly } Label { - visible: Config.fiatCurrency != '' - text: Config.fiatCurrency + visible: Daemon.fx.fiatCurrency != '' + text: Daemon.fx.fiatCurrency color: Material.accentColor } - Item { visible: Config.fiatCurrency == ''; width: 1; height: 1; Layout.columnSpan: 2 } + Item { visible: Daemon.fx.fiatCurrency == ''; width: 1; height: 1; Layout.columnSpan: 2 } RowLayout { Layout.columnSpan: 4 @@ -356,22 +356,22 @@ Pane { if (amountFiat.activeFocus) return var a = Config.unitsToSats(amount.text) - amountFiat.text = Daemon.fiatValue(a) + amountFiat.text = Daemon.fx.fiatValue(a) } } Connections { target: amountFiat function onTextChanged() { if (amountFiat.activeFocus) { - amount.text = Daemon.satoshiValue(amountFiat.text) + amount.text = Daemon.fx.satoshiValue(amountFiat.text) } } } Connections { - target: Network - function onFiatUpdated() { + target: Daemon.fx + function onQuotesUpdated() { var a = Config.unitsToSats(amount.text) - amountFiat.text = Daemon.fiatValue(a) + amountFiat.text = Daemon.fx.fiatValue(a) } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 0da854df6..4083cd41a 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -52,19 +52,19 @@ Pane { TextField { id: amountFiat - visible: Config.fiatCurrency != '' + visible: Daemon.fx.fiatCurrency != '' font.family: FixedFont placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhPreferNumbers } Label { - visible: Config.fiatCurrency != '' - text: Config.fiatCurrency + visible: Daemon.fx.fiatCurrency != '' + text: Daemon.fx.fiatCurrency color: Material.accentColor } - Item { visible: Config.fiatCurrency == ''; height: 1; Layout.columnSpan: 2; Layout.fillWidth: true } + Item { visible: Daemon.fx.fiatCurrency == ''; height: 1; Layout.columnSpan: 2; Layout.fillWidth: true } Item { width: 1; height: 1 } // workaround colspan on baseunit messing up row above @@ -115,22 +115,22 @@ Pane { if (amountFiat.activeFocus) return var a = Config.unitsToSats(amount.text) - amountFiat.text = Daemon.fiatValue(a) + amountFiat.text = Daemon.fx.fiatValue(a) } } Connections { target: amountFiat function onTextChanged() { if (amountFiat.activeFocus) { - amount.text = Daemon.satoshiValue(amountFiat.text) + amount.text = Daemon.fx.satoshiValue(amountFiat.text) } } } Connections { - target: Network - function onFiatUpdated() { + target: Daemon.fx + function onQuotesUpdated() { var a = Config.unitsToSats(amount.text) - amountFiat.text = Daemon.fiatValue(a) + amountFiat.text = Daemon.fx.fiatValue(a) } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 056f5f874..0f209bfc7 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -16,6 +16,7 @@ from .qewallet import QEWallet from .qeqr import QEQRParser, QEQRImageProvider from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin +from .qefx import QEFX class QEAppController(QObject): userNotify = pyqtSignal(str) @@ -83,7 +84,6 @@ class ElectrumQmlApplication(QGuiApplication): self.logger = get_logger(__name__) - #ElectrumQmlApplication._config = config ElectrumQmlApplication._daemon = daemon qmlRegisterType(QEWalletListModel, 'org.electrum', 1, 0, 'WalletListModel') @@ -91,6 +91,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEWalletDB, 'org.electrum', 1, 0, 'WalletDB') qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin') qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser') + qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') @@ -121,8 +122,6 @@ class ElectrumQmlApplication(QGuiApplication): 'protocol_version': version.PROTOCOL_VERSION }) - self._qeconfig.fiatCurrencyChanged.connect(self._qedaemon.setFiatCurrency) - qInstallMessageHandler(self.message_handler) # get notified whether root QML document loads or not diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index ec9c27904..a8c973a05 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -68,16 +68,6 @@ class QEConfig(QObject): self.config.amt_add_thousands_sep = checked self.thousandsSeparatorChanged.emit() - fiatCurrencyChanged = pyqtSignal() - @pyqtProperty(str, notify=fiatCurrencyChanged) - def fiatCurrency(self): - return self.config.get('currency') - - @fiatCurrency.setter - def fiatCurrency(self, currency): - self.config.set_key('currency', currency) - self.fiatCurrencyChanged.emit() - @pyqtSlot('qint64', result=str) @pyqtSlot('qint64', bool, result=str) def formatSats(self, satoshis, with_unit=False): diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index c2688c44f..c8c66191f 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -8,9 +8,9 @@ from electrum.util import register_callback, get_new_wallet_name, WalletFileExce from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import WalletStorage, StorageReadWriteError -from electrum.bitcoin import COIN from .qewallet import QEWallet +from .qefx import QEFX # wallet list model. supports both wallet basenames (wallet file basenames) # and whole Wallet instances (loaded wallets) @@ -86,6 +86,7 @@ class QEDaemon(QObject): def __init__(self, daemon, parent=None): super().__init__(parent) self.daemon = daemon + self.qefx = QEFX(daemon.fx, daemon.config) _logger = get_logger(__name__) _loaded_wallets = QEWalletListModel() @@ -98,7 +99,7 @@ class QEDaemon(QObject): activeWalletsChanged = pyqtSignal() availableWalletsChanged = pyqtSignal() walletOpenError = pyqtSignal([str], arguments=["error"]) - currenciesChanged = pyqtSignal() + fxChanged = pyqtSignal() @pyqtSlot() @pyqtSlot(str) @@ -135,31 +136,6 @@ class QEDaemon(QObject): self._logger.error(str(e)) self.walletOpenError.emit(str(e)) - @pyqtSlot(str, result=str) - def fiatValue(self, satoshis): - rate = self.daemon.fx.exchange_rate() - try: - sd = Decimal(satoshis) - if sd == 0: - return '' - except: - return '' - return self.daemon.fx.value_str(satoshis,rate) - - # TODO: move conversion to FxThread - @pyqtSlot(str, result=str) - def satoshiValue(self, fiat): - rate = self.daemon.fx.exchange_rate() - try: - fd = Decimal(fiat) - except: - return '' - v = fd / Decimal(rate) * COIN - return '' if v.is_nan() else self.daemon.config.format_amount(v) - - @pyqtSlot() - def setFiatCurrency(self): - self.daemon.fx.set_currency(self.daemon.config.get('currency')) @pyqtProperty('QString') def path(self): @@ -180,6 +156,7 @@ class QEDaemon(QObject): return self._available_wallets - @pyqtProperty('QVariantList', notify=currenciesChanged) - def currencies(self): - return [''] + self.daemon.fx.get_currencies(False) + @pyqtProperty(QEFX, notify=fxChanged) + def fx(self): + return self.qefx + diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py new file mode 100644 index 000000000..08b98a69d --- /dev/null +++ b/electrum/gui/qml/qefx.py @@ -0,0 +1,107 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from decimal import Decimal + +from electrum.logging import get_logger +from electrum.exchange_rate import FxThread +from electrum.simple_config import SimpleConfig +from electrum.util import register_callback +from electrum.bitcoin import COIN + +class QEFX(QObject): + def __init__(self, fxthread: FxThread, config: SimpleConfig, parent=None): + super().__init__(parent) + self.fx = fxthread + self.config = config + register_callback(self.on_quotes, ['on_quotes']) + register_callback(self.on_history, ['on_history']) + + _logger = get_logger(__name__) + + quotesUpdated = pyqtSignal() + def on_quotes(self, event, *args): + self._logger.debug('new quotes') + self.quotesUpdated.emit() + + historyUpdated = pyqtSignal() + def on_history(self, event, *args): + self._logger.debug('new history') + self.historyUpdated.emit() + + currenciesChanged = pyqtSignal() + @pyqtProperty('QVariantList', notify=currenciesChanged) + def currencies(self): + return [''] + self.fx.get_currencies(self.historyRates) + + rateSourcesChanged = pyqtSignal() + @pyqtProperty('QVariantList', notify=rateSourcesChanged) + def rateSources(self): + return self.fx.get_exchanges_by_ccy(self.fiatCurrency, self.historyRates) + + fiatCurrencyChanged = pyqtSignal() + @pyqtProperty(str, notify=fiatCurrencyChanged) + def fiatCurrency(self): + return self.fx.get_currency() + + @fiatCurrency.setter + def fiatCurrency(self, currency): + if currency != self.fiatCurrency: + self.fx.set_currency(currency) + self.enabled = currency != '' + self.fiatCurrencyChanged.emit() + self.rateSourcesChanged.emit() + + historyRatesChanged = pyqtSignal() + @pyqtProperty(bool, notify=historyRatesChanged) + def historyRates(self): + return self.fx.get_history_config() + + @historyRates.setter + def historyRates(self, checked): + if checked != self.historyRates: + self.fx.set_history_config(checked) + self.historyRatesChanged.emit() + self.rateSourcesChanged.emit() + + rateSourceChanged = pyqtSignal() + @pyqtProperty(str, notify=rateSourceChanged) + def rateSource(self): + return self.fx.config_exchange() + + @rateSource.setter + def rateSource(self, source): + if source != self.rateSource: + self.fx.set_exchange(source) + self.rateSourceChanged.emit() + + enabledChanged = pyqtSignal() + @pyqtProperty(bool, notify=enabledChanged) + def enabled(self): + return self.fx.is_enabled() + + @enabled.setter + def enabled(self, enable): + if enable != self.enabled: + self.fx.set_enabled(enable) + self.enabledChanged.emit() + + @pyqtSlot(str, result=str) + def fiatValue(self, satoshis): + rate = self.fx.exchange_rate() + try: + sd = Decimal(satoshis) + if sd == 0: + return '' + except: + return '' + return self.fx.value_str(satoshis,rate) + + @pyqtSlot(str, result=str) + def satoshiValue(self, fiat): + rate = self.fx.exchange_rate() + try: + fd = Decimal(fiat) + except: + return '' + v = fd / Decimal(rate) * COIN + return '' if v.is_nan() else self.config.format_amount(v) diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index 18eeb95f1..3522185e9 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -15,7 +15,6 @@ class QENetwork(QObject): register_callback(self.on_proxy_set, ['proxy_set']) register_callback(self.on_status, ['status']) register_callback(self.on_fee_histogram, ['fee_histogram']) - register_callback(self.on_fiat, ['on_quotes','on_history']) _logger = get_logger(__name__) @@ -27,7 +26,6 @@ class QENetwork(QObject): proxyChanged = pyqtSignal() statusChanged = pyqtSignal() feeHistogramUpdated = pyqtSignal() - fiatUpdated = pyqtSignal() # shared signal for static properties dataChanged = pyqtSignal() @@ -62,10 +60,6 @@ class QENetwork(QObject): self._logger.debug('fee histogram updated') self.feeHistogramUpdated.emit() - def on_fiat(self, event, *args): - self._logger.debug('new fiat quotes') - self.fiatUpdated.emit() - @pyqtProperty(int,notify=heightChanged) def height(self): return self._height From a8ff969ad71c2b20de4ec94c5c586a835fcf338e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 5 Apr 2022 14:53:42 +0200 Subject: [PATCH 102/218] send/receive amounts same style --- electrum/gui/qml/components/Receive.qml | 126 ++++++++---------------- electrum/gui/qml/components/Send.qml | 42 +++++--- 2 files changed, 70 insertions(+), 98 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 3a5aa2ff1..cc6eac92e 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -13,8 +13,8 @@ Pane { GridLayout { id: form width: parent.width - rowSpacing: 10 - columnSpacing: 10 + rowSpacing: constants.paddingSmall + columnSpacing: constants.paddingSmall columns: 4 Label { @@ -23,22 +23,22 @@ Pane { TextField { id: message + placeholderText: qsTr('Description of payment request') Layout.columnSpan: 3 Layout.fillWidth: true } Label { - text: qsTr('Requested Amount') + text: qsTr('Request') wrapMode: Text.WordWrap - Layout.preferredWidth: 50 // trigger wordwrap Layout.rightMargin: constants.paddingXLarge - Layout.rowSpan: 2 } TextField { id: amount font.family: FixedFont - Layout.fillWidth: true + Layout.preferredWidth: parent.width /2 + placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhPreferNumbers } @@ -47,103 +47,63 @@ Pane { color: Material.accentColor } - ColumnLayout { - Layout.rowSpan: 2 - Layout.preferredWidth: rootItem.width /3 - Layout.leftMargin: constants.paddingXLarge + Item { width: 1; height: 1; Layout.fillWidth: true } - Label { - text: qsTr('Expires after') - Layout.fillWidth: false - } - - ComboBox { - id: expires - Layout.fillWidth: true - 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('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}) - expiresmodel.append({'text': qsTr('1 month'), 'value': 31*7*24*60*60}) - expiresmodel.append({'text': qsTr('Never'), 'value': 0}) - expires.currentIndex = 0 - } - } - } - } + Item { visible: Daemon.fx.enabled; width: 1; height: 1 } TextField { id: amountFiat - visible: Daemon.fx.fiatCurrency != '' + visible: Daemon.fx.enabled font.family: FixedFont - Layout.fillWidth: true + Layout.preferredWidth: parent.width /2 + placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhDigitsOnly } Label { - visible: Daemon.fx.fiatCurrency != '' + visible: Daemon.fx.enabled text: Daemon.fx.fiatCurrency color: Material.accentColor } - Item { visible: Daemon.fx.fiatCurrency == ''; width: 1; height: 1; Layout.columnSpan: 2 } + Item { width: 1; height: 1; Layout.fillWidth: true } - RowLayout { - Layout.columnSpan: 4 - Layout.alignment: Qt.AlignHCenter - visible: false - CheckBox { - id: cb_onchain - text: qsTr('Onchain') - checked: true - contentItem: RowLayout { - Text { - text: cb_onchain.text - font: cb_onchain.font - opacity: enabled ? 1.0 : 0.3 - color: Material.foreground - verticalAlignment: Text.AlignVCenter - leftPadding: cb_onchain.indicator.width + cb_onchain.spacing - } - Image { - x: 16 - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - source: '../../icons/bitcoin.png' - } + Label { + text: qsTr('Expires after') + Layout.fillWidth: false + } + + ComboBox { + id: expires + Layout.columnSpan: 2 + + 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('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}) + expiresmodel.append({'text': qsTr('1 month'), 'value': 31*7*24*60*60}) + expiresmodel.append({'text': qsTr('Never'), 'value': 0}) + expires.currentIndex = 0 } } - CheckBox { - id: cb_lightning - text: qsTr('Lightning') - enabled: false - contentItem: RowLayout { - Text { - text: cb_lightning.text - font: cb_lightning.font - opacity: enabled ? 1.0 : 0.3 - color: Material.foreground - verticalAlignment: Text.AlignVCenter - leftPadding: cb_lightning.indicator.width + cb_lightning.spacing - } - Image { - x: 16 - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - source: '../../icons/lightning.png' - } - } + // redefine contentItem, as the default crops the widest item + contentItem: Label { + text: expires.currentText + padding: constants.paddingLarge + font.pixelSize: constants.fontSizeMedium } } + Item { width: 1; height: 1; Layout.fillWidth: true } + Button { Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 4083cd41a..d2f8e96fc 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -8,10 +8,12 @@ Pane { GridLayout { width: parent.width - columns: 6 + rowSpacing: constants.paddingSmall + columnSpacing: constants.paddingSmall + columns: 4 BalanceSummary { - Layout.columnSpan: 6 + Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter } @@ -19,19 +21,20 @@ Pane { text: qsTr('Recipient') } - TextField { + TextArea { id: address - Layout.columnSpan: 4 + Layout.columnSpan: 2 Layout.fillWidth: true font.family: FixedFont + wrapMode: Text.Wrap placeholderText: qsTr('Paste address or invoice') } ToolButton { icon.source: '../../icons/copy.png' icon.color: 'transparent' - icon.height: 16 - icon.width: 16 + icon.height: constants.iconSizeSmall + icon.width: constants.iconSizeSmall } Label { @@ -42,31 +45,38 @@ Pane { id: amount font.family: FixedFont placeholderText: qsTr('Amount') + Layout.preferredWidth: parent.width /2 inputMethodHints: Qt.ImhPreferNumbers } Label { - text: Config.baseUnit + ' ' // add spaces for easy right margin + text: Config.baseUnit color: Material.accentColor + Layout.fillWidth: true } + Item { width: 1; height: 1 } + + + Item { width: 1; height: 1; visible: Daemon.fx.enabled } + TextField { id: amountFiat - visible: Daemon.fx.fiatCurrency != '' + visible: Daemon.fx.enabled font.family: FixedFont + Layout.preferredWidth: parent.width /2 placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhPreferNumbers } Label { - visible: Daemon.fx.fiatCurrency != '' + visible: Daemon.fx.enabled text: Daemon.fx.fiatCurrency color: Material.accentColor + Layout.fillWidth: true } - Item { visible: Daemon.fx.fiatCurrency == ''; height: 1; Layout.columnSpan: 2; Layout.fillWidth: true } - - Item { width: 1; height: 1 } // workaround colspan on baseunit messing up row above + Item { visible: Daemon.fx.enabled ; height: 1; width: 1 } Label { text: qsTr('Fee') @@ -76,13 +86,15 @@ Pane { id: fee font.family: FixedFont placeholderText: qsTr('sat/vB') - Layout.columnSpan: 5 + Layout.columnSpan: 2 } + Item { width: 1; height: 1 } + RowLayout { - Layout.columnSpan: 6 + Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter - spacing: 10 + spacing: constants.paddingMedium Button { text: qsTr('Pay') From 7013f9d26bce81e5b395df6bee7ee37126a32299 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 5 Apr 2022 17:24:20 +0200 Subject: [PATCH 103/218] generate and parse bip 21 qr codes --- electrum/gui/qml/components/Receive.qml | 42 +++++++++---------- electrum/gui/qml/components/RequestDialog.qml | 14 +++++-- electrum/gui/qml/components/Scan.qml | 17 ++++++++ electrum/gui/qml/components/Send.qml | 40 +++++++++--------- electrum/gui/qml/qebitcoin.py | 18 ++++++++ electrum/gui/qml/qeconfig.py | 4 ++ electrum/gui/qml/qeqr.py | 7 ++-- electrum/gui/qml/qerequestlistmodel.py | 7 ++-- 8 files changed, 98 insertions(+), 51 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index cc6eac92e..6598d606c 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -40,6 +40,21 @@ Pane { Layout.preferredWidth: parent.width /2 placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhPreferNumbers + + property string textAsSats + onTextChanged: { + textAsSats = Config.unitsToSats(amount.text) + if (amountFiat.activeFocus) + return + amountFiat.text = Daemon.fx.fiatValue(amount.textAsSats) + } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = amount.textAsSats != 0 ? Config.satsToUnits(amount.textAsSats) : '' + } + } } Label { @@ -58,6 +73,10 @@ Pane { Layout.preferredWidth: parent.width /2 placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhDigitsOnly + onTextChanged: { + if (amountFiat.activeFocus) + amount.text = Daemon.fx.satoshiValue(amountFiat.text) + } } Label { @@ -216,7 +235,7 @@ Pane { text: qsTr('Timestamp: ') } Label { - text: model.timestamp + text: model.date } Label { @@ -301,32 +320,11 @@ Pane { } dialog.open() } - } - - Connections { - target: Daemon.currentWallet function onRequestStatusChanged(key, status) { Daemon.currentWallet.requestModel.updateRequest(key, status) } } - Connections { - target: amount - function onTextChanged() { - if (amountFiat.activeFocus) - return - var a = Config.unitsToSats(amount.text) - amountFiat.text = Daemon.fx.fiatValue(a) - } - } - Connections { - target: amountFiat - function onTextChanged() { - if (amountFiat.activeFocus) { - amount.text = Daemon.fx.satoshiValue(amountFiat.text) - } - } - } Connections { target: Daemon.fx function onQuotesUpdated() { diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index b3636fccc..33dd68f7c 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -56,13 +56,12 @@ Dialog { } Image { + id: qr Layout.columnSpan: 3 Layout.alignment: Qt.AlignHCenter Layout.topMargin: constants.paddingSmall Layout.bottomMargin: constants.paddingSmall - source: 'image://qrgen/' + modelItem.address - Rectangle { property int size: 57 // should be qr pixel multiple color: 'white' @@ -131,7 +130,7 @@ Dialog { } Label { visible: modelItem.amount > 0 - text: Config.formatSats(modelItem.amount, false) + text: Config.formatSats(modelItem.amount) font.family: FixedFont font.pixelSize: constants.fontSizeLarge } @@ -181,4 +180,13 @@ Dialog { modelItem = Daemon.currentWallet.get_request(key) } } + + Component.onCompleted: { + var bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.exp) + qr.source = 'image://qrgen/' + bip21uri + } + + Bitcoin { + id: bitcoin + } } diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml index 35f18845a..5ad3d4344 100644 --- a/electrum/gui/qml/components/Scan.qml +++ b/electrum/gui/qml/components/Scan.qml @@ -1,6 +1,8 @@ import QtQuick 2.6 import QtQuick.Controls 2.0 +import org.electrum 1.0 + Item { id: scanPage property string title: qsTr('Scan') @@ -8,6 +10,8 @@ Item { property bool toolbar: false property string scanData + property var invoiceData: undefined + property string error signal found @@ -18,6 +22,16 @@ Item { onFound: { scanPage.scanData = scanData + var invoice = bitcoin.parse_uri(scanData) + if (invoice['error']) { + error = invoice['error'] + console.log(error) + app.stack.pop() + return + } + + invoiceData = invoice + console.log(invoiceData['address']) scanPage.found() app.stack.pop() } @@ -31,4 +45,7 @@ Item { onClicked: app.stack.pop() } + Bitcoin { + id: bitcoin + } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index d2f8e96fc..dfb1df56b 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -47,6 +47,20 @@ Pane { placeholderText: qsTr('Amount') Layout.preferredWidth: parent.width /2 inputMethodHints: Qt.ImhPreferNumbers + property string textAsSats + onTextChanged: { + textAsSats = Config.unitsToSats(amount.text) + if (amountFiat.activeFocus) + return + amountFiat.text = Daemon.fx.fiatValue(amount.textAsSats) + } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = amount.textAsSats != 0 ? Config.satsToUnits(amount.textAsSats) : '' + } + } } Label { @@ -67,6 +81,10 @@ Pane { Layout.preferredWidth: parent.width /2 placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhPreferNumbers + onTextChanged: { + if (amountFiat.activeFocus) + amount.text = Daemon.fx.satoshiValue(amountFiat.text) + } } Label { @@ -113,31 +131,15 @@ Pane { onClicked: { var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) page.onFound.connect(function() { - console.log('got ' + page.scanData) - address.text = page.scanData + console.log('got ' + page.invoiceData) + address.text = page.invoiceData['address'] + amount.text = Config.formatSats(page.invoiceData['amount']) }) } } } } - Connections { - target: amount - function onTextChanged() { - if (amountFiat.activeFocus) - return - var a = Config.unitsToSats(amount.text) - amountFiat.text = Daemon.fx.fiatValue(a) - } - } - Connections { - target: amountFiat - function onTextChanged() { - if (amountFiat.activeFocus) { - amount.text = Daemon.fx.satoshiValue(amountFiat.text) - } - } - } Connections { target: Daemon.fx function onQuotesUpdated() { diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 545b8db66..d1f6b681c 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject @@ -7,6 +8,7 @@ from electrum.keystore import bip39_is_checksum_valid from electrum.bip32 import is_bip32_derivation from electrum.slip39 import decode_mnemonic, Slip39Error from electrum import mnemonic +from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI class QEBitcoin(QObject): def __init__(self, config, parent=None): @@ -111,3 +113,19 @@ class QEBitcoin(QObject): @pyqtSlot(str, result=bool) def verify_derivation_path(self, path): return is_bip32_derivation(path) + + @pyqtSlot(str, result='QVariantMap') + def parse_uri(self, uri: str) -> dict: + try: + return parse_URI(uri) + except InvalidBitcoinURI as e: + return { 'error': str(e) } + + @pyqtSlot(str, 'qint64', str, int, int, result=str) + def create_uri(self, address, satoshis, message, timestamp, expiry): + extra_params = {} + if expiry: + extra_params['time'] = str(timestamp) + extra_params['exp'] = str(expiry) + + return create_bip21_uri(address, satoshis, message, extra_query_params=extra_params) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index a8c973a05..f385c3ba4 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -100,3 +100,7 @@ class QEConfig(QObject): #amount = Decimal(max_prec_amount) / Decimal(pow(10, self.max_precision()-self.decimal_point())) #return int(amount) #Decimal(amount) if not self.is_int else int(amount) return 0 + + @pyqtSlot('quint64', result=float) + def satsToUnits(self, satoshis): + return satoshis / pow(10,self.config.decimal_point) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index b32ca1c92..3bd856e32 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -4,8 +4,7 @@ from PyQt5.QtQuick import QQuickImageProvider import asyncio import qrcode -#from qrcode.image.styledpil import StyledPilImage -#from qrcode.image.styles.moduledrawers import * + from PIL import Image, ImageQt from electrum.logging import get_logger @@ -126,10 +125,10 @@ class QEQRImageProvider(QQuickImageProvider): def requestImage(self, qstr, size): self._logger.debug('QR requested for %s' % qstr) - qr = qrcode.QRCode(version=1, box_size=8, border=2) + qr = qrcode.QRCode(version=1, box_size=6, border=2) qr.add_data(qstr) qr.make(fit=True) - pimg = qr.make_image(fill_color='black', back_color='white') #image_factory=StyledPilImage, module_drawer=CircleModuleDrawer()) + pimg = qr.make_image(fill_color='black', back_color='white') self.qimg = ImageQt.ImageQt(pimg) return self.qimg, self.qimg.size() diff --git a/electrum/gui/qml/qerequestlistmodel.py b/electrum/gui/qml/qerequestlistmodel.py index 430f8a132..25ec63ca6 100644 --- a/electrum/gui/qml/qerequestlistmodel.py +++ b/electrum/gui/qml/qerequestlistmodel.py @@ -14,7 +14,7 @@ class QERequestListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES=('key','type','timestamp','message','amount','status','address') + _ROLE_NAMES=('key','type','timestamp','date','message','amount','status','address','exp') _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])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -46,10 +46,11 @@ class QERequestListModel(QAbstractListModel): 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['timestamp'] = req.time + item['date'] = format_time(item['timestamp']) item['amount'] = req.get_amount_sat() item['message'] = req.message + item['exp'] = req.exp if req.type == 0: # OnchainInvoice item['key'] = item['address'] = req.get_address() elif req.type == 2: # LNInvoice From fad2d879efde6ed143b3314251ef3bb48f65b1de Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 5 Apr 2022 18:06:30 +0200 Subject: [PATCH 104/218] UI fixes --- electrum/gui/qml/components/Addresses.qml | 2 ++ electrum/gui/qml/components/QRScan.qml | 2 ++ electrum/gui/qml/components/Receive.qml | 2 +- electrum/gui/qml/components/RequestDialog.qml | 16 +++++++++------- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index f8aeaa977..51120b75c 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -8,6 +8,7 @@ import org.electrum 1.0 Pane { id: rootItem padding: 0 + width: parent.width property string title: Daemon.currentWallet.name + ' - ' + qsTr('Addresses') ColumnLayout { @@ -73,6 +74,7 @@ Pane { Label { font.family: FixedFont text: model.address + elide: Text.ElideMiddle Layout.fillWidth: true } diff --git a/electrum/gui/qml/components/QRScan.qml b/electrum/gui/qml/components/QRScan.qml index b681a92ec..514fd2591 100644 --- a/electrum/gui/qml/components/QRScan.qml +++ b/electrum/gui/qml/components/QRScan.qml @@ -24,12 +24,14 @@ Item { Rectangle { width: parent.width height: (parent.height - parent.width) / 2 + visible: camera.cameraStatus == Camera.ActiveStatus anchors.top: parent.top color: Qt.rgba(0,0,0,0.5) } Rectangle { width: parent.width height: (parent.height - parent.width) / 2 + visible: camera.cameraStatus == Camera.ActiveStatus anchors.bottom: parent.bottom color: Qt.rgba(0,0,0,0.5) } diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 6598d606c..4b73042e2 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -85,7 +85,7 @@ Pane { color: Material.accentColor } - Item { width: 1; height: 1; Layout.fillWidth: true } + Item { visible: Daemon.fx.enabled; width: 1; height: 1; Layout.fillWidth: true } Label { text: qsTr('Expires after') diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 33dd68f7c..b1633f45f 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -46,18 +46,18 @@ Dialog { id: rootLayout width: parent.width rowSpacing: constants.paddingMedium - columns: 3 + columns: 4 Rectangle { height: 1 Layout.fillWidth: true - Layout.columnSpan: 3 + Layout.columnSpan: 4 color: Material.accentColor } Image { id: qr - Layout.columnSpan: 3 + Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter Layout.topMargin: constants.paddingSmall Layout.bottomMargin: constants.paddingSmall @@ -84,12 +84,12 @@ Dialog { Rectangle { height: 1 Layout.fillWidth: true - Layout.columnSpan: 3 + Layout.columnSpan: 4 color: Material.accentColor } RowLayout { - Layout.columnSpan: 3 + Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter Button { icon.source: '../../icons/delete.png' @@ -117,7 +117,7 @@ Dialog { } Label { visible: modelItem.message != '' - Layout.columnSpan: 2 + Layout.columnSpan: 3 Layout.fillWidth: true wrapMode: Text.WordWrap text: modelItem.message @@ -137,6 +137,7 @@ Dialog { Label { visible: modelItem.amount > 0 Layout.fillWidth: true + Layout.columnSpan: 2 text: Config.baseUnit color: Material.accentColor font.pixelSize: constants.fontSizeLarge @@ -147,6 +148,7 @@ Dialog { } Label { Layout.fillWidth: true + Layout.columnSpan: 2 font.family: FixedFont font.pixelSize: constants.fontSizeLarge wrapMode: Text.WrapAnywhere @@ -163,7 +165,7 @@ Dialog { text: qsTr('Status') } Label { - Layout.columnSpan: 2 + Layout.columnSpan: 3 Layout.fillWidth: true font.pixelSize: constants.fontSizeLarge text: modelItem.status From f2a9b5d06a0d780fdd507d41f5b085c21e557d99 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Apr 2022 13:49:40 +0200 Subject: [PATCH 105/218] add option for unformatted numbers to string --- electrum/gui/qml/components/Receive.qml | 2 +- electrum/gui/qml/components/Send.qml | 2 +- electrum/gui/qml/qefx.py | 18 ++++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 4b73042e2..126fb4cbb 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -75,7 +75,7 @@ Pane { inputMethodHints: Qt.ImhDigitsOnly onTextChanged: { if (amountFiat.activeFocus) - amount.text = Daemon.fx.satoshiValue(amountFiat.text) + amount.text = text == '' ? '' : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index dfb1df56b..223863d8e 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -83,7 +83,7 @@ Pane { inputMethodHints: Qt.ImhPreferNumbers onTextChanged: { if (amountFiat.activeFocus) - amount.text = Daemon.fx.satoshiValue(amountFiat.text) + amount.text = text == '' ? '' : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) } } diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index 08b98a69d..148484d1f 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -86,7 +86,8 @@ class QEFX(QObject): self.enabledChanged.emit() @pyqtSlot(str, result=str) - def fiatValue(self, satoshis): + @pyqtSlot(str, bool, result=str) + def fiatValue(self, satoshis, plain=True): rate = self.fx.exchange_rate() try: sd = Decimal(satoshis) @@ -94,14 +95,23 @@ class QEFX(QObject): return '' except: return '' - return self.fx.value_str(satoshis,rate) + if plain: + return self.fx.ccy_amount_str(self.fx.fiat_value(satoshis, rate), False) + else: + return self.fx.value_str(satoshis,rate) @pyqtSlot(str, result=str) - def satoshiValue(self, fiat): + @pyqtSlot(str, bool, result=str) + def satoshiValue(self, fiat, plain=True): rate = self.fx.exchange_rate() try: fd = Decimal(fiat) except: return '' v = fd / Decimal(rate) * COIN - return '' if v.is_nan() else self.config.format_amount(v) + if v.is_nan(): + return '' + if plain: + return str(v.to_integral_value()) + else: + return self.config.format_amount(v) From 6cb3a07500a202830728aa93c5d6202fa6626d89 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Apr 2022 13:52:21 +0200 Subject: [PATCH 106/218] move reusable controls into controls/ subdir --- electrum/gui/qml/components/OpenWallet.qml | 2 ++ electrum/gui/qml/components/Receive.qml | 2 ++ electrum/gui/qml/components/Scan.qml | 2 ++ electrum/gui/qml/components/Wallets.qml | 2 ++ .../gui/qml/components/{ => controls}/InfoTextArea.qml | 2 +- .../gui/qml/components/{ => controls}/MessageDialog.qml | 0 electrum/gui/qml/components/{ => controls}/MessagePane.qml | 0 .../qml/components/{ => controls}/NotificationPopup.qml | 0 .../qml/components/{ => controls}/PaneInsetBackground.qml | 7 ------- electrum/gui/qml/components/{ => controls}/QRScan.qml | 0 .../gui/qml/components/{ => controls}/SeedTextArea.qml | 0 electrum/gui/qml/components/main.qml | 2 ++ electrum/gui/qml/components/wizard/WCAutoConnect.qml | 1 + electrum/gui/qml/components/wizard/WCBIP39Refine.qml | 1 + electrum/gui/qml/components/wizard/WCConfirmSeed.qml | 1 + electrum/gui/qml/components/wizard/WCCreateSeed.qml | 1 + electrum/gui/qml/components/wizard/WCHaveSeed.qml | 1 + 17 files changed, 16 insertions(+), 8 deletions(-) rename electrum/gui/qml/components/{ => controls}/InfoTextArea.qml (88%) rename electrum/gui/qml/components/{ => controls}/MessageDialog.qml (100%) rename electrum/gui/qml/components/{ => controls}/MessagePane.qml (100%) rename electrum/gui/qml/components/{ => controls}/NotificationPopup.qml (100%) rename electrum/gui/qml/components/{ => controls}/PaneInsetBackground.qml (81%) rename electrum/gui/qml/components/{ => controls}/QRScan.qml (100%) rename electrum/gui/qml/components/{ => controls}/SeedTextArea.qml (100%) diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml index ea59d3030..4131a34fc 100644 --- a/electrum/gui/qml/components/OpenWallet.qml +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -4,6 +4,8 @@ import QtQuick.Controls 2.1 import org.electrum 1.0 +import "controls" + Pane { id: openwalletdialog diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 126fb4cbb..a082a4ffc 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -6,6 +6,8 @@ import QtQml.Models 2.1 import org.electrum 1.0 +import "controls" + Pane { id: rootItem visible: Daemon.currentWallet !== undefined diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml index 5ad3d4344..22cb6443e 100644 --- a/electrum/gui/qml/components/Scan.qml +++ b/electrum/gui/qml/components/Scan.qml @@ -3,6 +3,8 @@ import QtQuick.Controls 2.0 import org.electrum 1.0 +import "controls" + Item { id: scanPage property string title: qsTr('Scan') diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index d4ca72a14..5393b6c34 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -5,6 +5,8 @@ import QtQuick.Controls.Material 2.0 import org.electrum 1.0 +import "controls" + Pane { id: rootItem diff --git a/electrum/gui/qml/components/InfoTextArea.qml b/electrum/gui/qml/components/controls/InfoTextArea.qml similarity index 88% rename from electrum/gui/qml/components/InfoTextArea.qml rename to electrum/gui/qml/components/controls/InfoTextArea.qml index 5f9d2dbec..45110c124 100644 --- a/electrum/gui/qml/components/InfoTextArea.qml +++ b/electrum/gui/qml/components/controls/InfoTextArea.qml @@ -37,7 +37,7 @@ GridLayout { } Image { - source: iconStyle == InfoTextArea.IconStyle.Info ? "../../icons/info.png" : InfoTextArea.IconStyle.Warn ? "../../icons/warning.png" : InfoTextArea.IconStyle.Error ? "../../icons/expired.png" : "" + source: iconStyle == InfoTextArea.IconStyle.Info ? "../../../icons/info.png" : InfoTextArea.IconStyle.Warn ? "../../../icons/warning.png" : InfoTextArea.IconStyle.Error ? "../../../icons/expired.png" : "" anchors.left: parent.left anchors.top: parent.top anchors.leftMargin: 16 diff --git a/electrum/gui/qml/components/MessageDialog.qml b/electrum/gui/qml/components/controls/MessageDialog.qml similarity index 100% rename from electrum/gui/qml/components/MessageDialog.qml rename to electrum/gui/qml/components/controls/MessageDialog.qml diff --git a/electrum/gui/qml/components/MessagePane.qml b/electrum/gui/qml/components/controls/MessagePane.qml similarity index 100% rename from electrum/gui/qml/components/MessagePane.qml rename to electrum/gui/qml/components/controls/MessagePane.qml diff --git a/electrum/gui/qml/components/NotificationPopup.qml b/electrum/gui/qml/components/controls/NotificationPopup.qml similarity index 100% rename from electrum/gui/qml/components/NotificationPopup.qml rename to electrum/gui/qml/components/controls/NotificationPopup.qml diff --git a/electrum/gui/qml/components/PaneInsetBackground.qml b/electrum/gui/qml/components/controls/PaneInsetBackground.qml similarity index 81% rename from electrum/gui/qml/components/PaneInsetBackground.qml rename to electrum/gui/qml/components/controls/PaneInsetBackground.qml index f9719969a..8d4c316ac 100644 --- a/electrum/gui/qml/components/PaneInsetBackground.qml +++ b/electrum/gui/qml/components/controls/PaneInsetBackground.qml @@ -23,11 +23,4 @@ Rectangle { color: Qt.lighter(Material.background, 1.50) } color: Qt.darker(Material.background, 1.15) - Image { - source: '../../icons/electrum_lightblue.svg' - anchors.centerIn: parent - sourceSize.width: 128 - sourceSize.height: 128 - opacity: 0.1 - } } diff --git a/electrum/gui/qml/components/QRScan.qml b/electrum/gui/qml/components/controls/QRScan.qml similarity index 100% rename from electrum/gui/qml/components/QRScan.qml rename to electrum/gui/qml/components/controls/QRScan.qml diff --git a/electrum/gui/qml/components/SeedTextArea.qml b/electrum/gui/qml/components/controls/SeedTextArea.qml similarity index 100% rename from electrum/gui/qml/components/SeedTextArea.qml rename to electrum/gui/qml/components/controls/SeedTextArea.qml diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index b175505ac..61398a093 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -6,6 +6,8 @@ import QtQuick.Controls.Material 2.0 import QtQml 2.6 import QtMultimedia 5.6 +import "controls" + ApplicationWindow { id: app diff --git a/electrum/gui/qml/components/wizard/WCAutoConnect.qml b/electrum/gui/qml/components/wizard/WCAutoConnect.qml index c06321036..9b15d533c 100644 --- a/electrum/gui/qml/components/wizard/WCAutoConnect.qml +++ b/electrum/gui/qml/components/wizard/WCAutoConnect.qml @@ -2,6 +2,7 @@ import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 import ".." +import "../controls" WizardComponent { valid: true diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml index 872d712a7..27cb36dd4 100644 --- a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -5,6 +5,7 @@ import QtQuick.Controls 2.1 import org.electrum 1.0 import ".." +import "../controls" WizardComponent { valid: false diff --git a/electrum/gui/qml/components/wizard/WCConfirmSeed.qml b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml index a6b21d81e..2440291c9 100644 --- a/electrum/gui/qml/components/wizard/WCConfirmSeed.qml +++ b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml @@ -5,6 +5,7 @@ import QtQuick.Controls 2.1 import org.electrum 1.0 import ".." +import "../controls" WizardComponent { valid: false diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml index 28fe2e0c7..7c172842e 100644 --- a/electrum/gui/qml/components/wizard/WCCreateSeed.qml +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -5,6 +5,7 @@ import QtQuick.Controls 2.1 import org.electrum 1.0 import ".." +import "../controls" WizardComponent { valid: seedtext.text != '' diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml index da0440d5d..18599fee6 100644 --- a/electrum/gui/qml/components/wizard/WCHaveSeed.qml +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -6,6 +6,7 @@ import QtQuick.Controls.Material 2.0 import org.electrum 1.0 import ".." +import "../controls" WizardComponent { id: root From bbaf0fe5db82556b28ae4273fbf96c31d95be152 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Apr 2022 17:00:29 +0200 Subject: [PATCH 107/218] UI history page --- electrum/gui/qml/components/Constants.qml | 3 + electrum/gui/qml/components/History.qml | 150 ++++++++++------------ 2 files changed, 68 insertions(+), 85 deletions(-) diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index cbef3f31b..19ed130d6 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -21,4 +21,7 @@ QtObject { readonly property int iconSizeLarge: 32 readonly property int iconSizeXLarge: 48 readonly property int iconSizeXXLarge: 64 + + property color colorCredit: "#ff80ff80" + property color colorDebit: "#ffff8080" } diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index e51c3a5c5..54c627011 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -20,101 +20,81 @@ Pane { delegate: Item { id: delegate width: ListView.view.width - height: txinfo.height + height: delegateLayout.height - MouseArea { - anchors.fill: delegate - onClicked: extinfo.visible = !extinfo.visible - } + ColumnLayout { + id: delegateLayout + width: parent.width + spacing: 0 - GridLayout { - id: txinfo - columns: 3 - - x: 6 - width: delegate.width - 12 - - Image { - readonly property variant tx_icons : [ - "../../../gui/icons/unconfirmed.png", - "../../../gui/icons/clock1.png", - "../../../gui/icons/clock2.png", - "../../../gui/icons/clock3.png", - "../../../gui/icons/clock4.png", - "../../../gui/icons/clock5.png", - "../../../gui/icons/confirmed.png" - ] - - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 - Layout.alignment: Qt.AlignVCenter - Layout.rowSpan: 2 - source: tx_icons[Math.min(6,model.confirmations)] + Rectangle { + visible: index > 0 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingSmall + color: Qt.rgba(0,0,0,0.10) } - Label { - font.pixelSize: 18 + + ItemDelegate { Layout.fillWidth: true - text: model.label !== '' ? model.label : '' - color: model.label !== '' ? Material.accentColor : 'gray' - } - Label { - id: valueLabel - font.family: FixedFont - font.pixelSize: 15 - text: Config.formatSats(model.bc_value) - font.bold: true - color: model.incoming ? "#ff80ff80" : "#ffff8080" - } - Label { - font.pixelSize: 12 - text: model.date - } - Label { - font.pixelSize: 10 - text: 'fee: ' + (model.fee !== undefined ? model.fee : '0') - } + Layout.preferredHeight: txinfo.height + + GridLayout { + id: txinfo + columns: 3 + + x: constants.paddingSmall + width: delegate.width - 2*constants.paddingSmall + + Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1} + Image { + readonly property variant tx_icons : [ + "../../../gui/icons/unconfirmed.png", + "../../../gui/icons/clock1.png", + "../../../gui/icons/clock2.png", + "../../../gui/icons/clock3.png", + "../../../gui/icons/clock4.png", + "../../../gui/icons/clock5.png", + "../../../gui/icons/confirmed.png" + ] + + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + Layout.alignment: Qt.AlignVCenter + Layout.rowSpan: 2 + source: tx_icons[Math.min(6,model.confirmations)] + } - GridLayout { - id: extinfo - visible: false - columns: 2 - Layout.columnSpan: 3 - - Label { text: 'txid' } - Label { - font.pixelSize: 10 - text: model.txid - elide: Text.ElideMiddle - Layout.fillWidth: true - } - Label { text: 'height' } - Label { - font.pixelSize: 10 - text: model.height - } - Label { text: 'confirmations' } - Label { - font.pixelSize: 10 - text: model.confirmations - } - Label { text: 'address' } - Label { - font.pixelSize: 10 - elide: Text.ElideMiddle - Layout.fillWidth: true - text: { - for (var i=0; i < Object.keys(model.outputs).length; i++) { - if (model.outputs[i].value === model.bc_value) { - return model.outputs[i].address - } - } + Label { + font.pixelSize: constants.fontSizeLarge + Layout.fillWidth: true + text: model.label !== '' ? model.label : '' + color: model.label !== '' ? Material.accentColor : 'gray' + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Label { + id: valueLabel + font.family: FixedFont + font.pixelSize: constants.fontSizeMedium + text: Config.formatSats(model.bc_value) + font.bold: true + color: model.incoming ? constants.colorCredit : constants.colorDebit } + Label { + font.pixelSize: constants.fontSizeSmall + text: model.date + } + Label { + font.pixelSize: constants.fontSizeXSmall + Layout.alignment: Qt.AlignRight + text: model.fee !== undefined ? 'fee: ' + model.fee : '' + } + Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } } } - } - // as the items in the model are not bindings to QObjects, // hook up events that might change the appearance Connections { From 90416bd6e2fe7b6ec775b1c900e38b8c54134acc Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Apr 2022 20:13:57 +0200 Subject: [PATCH 108/218] let most signals not be handled in the UI thread, use quint64 type for slots where satoshis are expected --- electrum/gui/qml/qewallet.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 0604cbf43..73541df6d 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -81,8 +81,11 @@ class QEWallet(QObject): return self.wallet.is_up_to_date() def on_network(self, event, *args): - # Handle in GUI thread (_network_signal -> on_network_qt) - self._network_signal.emit(event, args) + if event == 'new_transaction': + # Handle in GUI thread (_network_signal -> on_network_qt) + self._network_signal.emit(event, args) + else: + self.on_network_qt(event, args) def on_network_qt(self, event, args=None): # note: we get events from all wallets! args are heterogenous so we can't @@ -241,10 +244,6 @@ class QEWallet(QObject): tx = self.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None) self._logger.info(str(tx.to_json())) - if len(tx.to_json()['outputs']) < 2: - self._logger.info('no change output??? : %s' % str(tx.to_json()['outputs'])) - return - use_rbf = bool(self.wallet.config.get('use_rbf', True)) tx.set_rbf(use_rbf) @@ -304,9 +303,9 @@ class QEWallet(QObject): self._requestModel.add_request(req) return addr - @pyqtSlot(int, 'QString', int) - @pyqtSlot(int, 'QString', int, bool) - @pyqtSlot(int, 'QString', int, bool, bool) + @pyqtSlot('quint64', 'QString', int) + @pyqtSlot('quint64', 'QString', int, bool) + @pyqtSlot('quint64', 'QString', int, bool, bool) def create_request(self, amount: int, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False): expiry = expiration #TODO: self.config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING) try: @@ -328,12 +327,6 @@ class QEWallet(QObject): 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) - @pyqtSlot('QString') def delete_request(self, key: str): self.wallet.delete_request(key) From c4c35c7cde97fc03adeeedb7a7ee36d7255106b0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Apr 2022 12:25:10 +0200 Subject: [PATCH 109/218] make Constants an Item and a child of main so it properly inherits material style settings from main --- electrum/gui/qml/components/Constants.qml | 3 ++- electrum/gui/qml/components/RequestDialog.qml | 4 ++-- electrum/gui/qml/components/WalletMainView.qml | 4 ++-- electrum/gui/qml/components/Wallets.qml | 2 +- .../gui/qml/components/controls/InfoTextArea.qml | 12 ++++++------ electrum/gui/qml/components/main.qml | 3 ++- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index 19ed130d6..5aee068ed 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -1,7 +1,7 @@ import QtQuick 2.6 import QtQuick.Controls.Material 2.0 -QtObject { +Item { readonly property int paddingTiny: 4 readonly property int paddingSmall: 8 readonly property int paddingMedium: 12 @@ -24,4 +24,5 @@ QtObject { property color colorCredit: "#ff80ff80" property color colorDebit: "#ffff8080" + property color mutedForeground: Qt.lighter(Material.background, 2) } diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index b1633f45f..29586ed6c 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -29,10 +29,10 @@ Dialog { text: dialog.title visible: dialog.title elide: Label.ElideRight - padding: 24 + padding: constants.paddingXLarge bottomPadding: 0 font.bold: true - font.pixelSize: 16 + font.pixelSize: constants.fontSizeMedium } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index c21faecbb..3cfe60464 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -53,12 +53,12 @@ Item { ColumnLayout { anchors.centerIn: parent width: parent.width - spacing: 40 + spacing: 2*constants.paddingXLarge visible: Daemon.currentWallet == null Label { text: qsTr('No wallet loaded') - font.pixelSize: 24 + font.pixelSize: constants.fontSizeXXLarge Layout.alignment: Qt.AlignHCenter } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 5393b6c34..c3a7696a3 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -88,7 +88,7 @@ Pane { } Label { - font.pixelSize: 18 + font.pixelSize: constants.fontSizeLarge text: model.name Layout.fillWidth: true } diff --git a/electrum/gui/qml/components/controls/InfoTextArea.qml b/electrum/gui/qml/components/controls/InfoTextArea.qml index 45110c124..ec55c9527 100644 --- a/electrum/gui/qml/components/controls/InfoTextArea.qml +++ b/electrum/gui/qml/components/controls/InfoTextArea.qml @@ -28,8 +28,8 @@ GridLayout { id: infotext Layout.fillWidth: true readOnly: true - rightPadding: 16 - leftPadding: 64 + rightPadding: constants.paddingLarge + leftPadding: 2*constants.iconSizeLarge wrapMode: TextInput.WordWrap textFormat: TextEdit.RichText background: Rectangle { @@ -40,10 +40,10 @@ GridLayout { source: iconStyle == InfoTextArea.IconStyle.Info ? "../../../icons/info.png" : InfoTextArea.IconStyle.Warn ? "../../../icons/warning.png" : InfoTextArea.IconStyle.Error ? "../../../icons/expired.png" : "" anchors.left: parent.left anchors.top: parent.top - anchors.leftMargin: 16 - anchors.topMargin: 16 - height: 32 - width: 32 + anchors.leftMargin: constants.paddingLarge + anchors.topMargin: constants.paddingLarge + height: constants.iconSizeLarge + width: constants.iconSizeLarge fillMode: Image.PreserveAspectCrop } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 61398a093..576c5c48d 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -21,7 +21,8 @@ ApplicationWindow Material.primary: Material.Indigo Material.accent: Material.LightBlue - property QtObject constants: Constants {} + property Item constants: appconstants + Constants { id: appconstants } property alias stack: mainStackView From 3868878be4af9e0ea7e91d26cfe8b7776d35ac1e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Apr 2022 14:06:59 +0200 Subject: [PATCH 110/218] filter out more unneeded files for packaging --- contrib/android/buildozer_qml.spec | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec index b012034a0..e21c57c89 100644 --- a/contrib/android/buildozer_qml.spec +++ b/contrib/android/buildozer_qml.spec @@ -21,10 +21,15 @@ source.exclude_exts = spec # (list) List of directory to exclude (let empty to not exclude anything) source.exclude_dirs = bin, build, dist, contrib, env, electrum/tests, + electrum/www, electrum/gui/qt, electrum/gui/kivy, packages/qdarkstyle, - packages/qtpy + packages/qtpy, + packages/bin, + packages/share, + packages/pkg_resources, + packages/setuptools # (list) List of exclusions using pattern matching source.exclude_patterns = Makefile,setup*, From 5c7060fffb981fa3145ad1013076a0ee359d4da4 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Apr 2022 16:10:38 +0200 Subject: [PATCH 111/218] add (today, yesterday, last week, last month, older) sections to history --- electrum/gui/qml/components/History.qml | 217 +++++++++++++-------- electrum/gui/qml/qetransactionlistmodel.py | 38 +++- 2 files changed, 165 insertions(+), 90 deletions(-) diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 54c627011..e14e57848 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -2,6 +2,7 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 import QtQuick.Controls.Material 2.0 +import QtQml.Models 2.2 import org.electrum 1.0 @@ -15,99 +16,149 @@ Pane { width: parent.width height: parent.height - model: Daemon.currentWallet.historyModel + model: visualModel - delegate: Item { - id: delegate + section.property: 'section' + section.criteria: ViewSection.FullString + section.delegate: RowLayout { width: ListView.view.width - height: delegateLayout.height - - ColumnLayout { - id: delegateLayout - width: parent.width - spacing: 0 - - Rectangle { - visible: index > 0 - Layout.fillWidth: true - Layout.preferredHeight: constants.paddingSmall - color: Qt.rgba(0,0,0,0.10) - } - + required property string section + Label { + text: section == 'today' + ? qsTr('Today') + : section == 'yesterday' + ? qsTr('Yesterday') + : section == 'lastweek' + ? qsTr('Last week') + : section == 'lastmonth' + ? qsTr('Last month') + : qsTr('Older') + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingLarge + font.pixelSize: constants.fontSizeLarge + color: constants.mutedForeground + } + } - ItemDelegate { - Layout.fillWidth: true - Layout.preferredHeight: txinfo.height - - GridLayout { - id: txinfo - columns: 3 - - x: constants.paddingSmall - width: delegate.width - 2*constants.paddingSmall - - Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1} - Image { - readonly property variant tx_icons : [ - "../../../gui/icons/unconfirmed.png", - "../../../gui/icons/clock1.png", - "../../../gui/icons/clock2.png", - "../../../gui/icons/clock3.png", - "../../../gui/icons/clock4.png", - "../../../gui/icons/clock5.png", - "../../../gui/icons/confirmed.png" - ] - - Layout.preferredWidth: constants.iconSizeLarge - Layout.preferredHeight: constants.iconSizeLarge - Layout.alignment: Qt.AlignVCenter - Layout.rowSpan: 2 - source: tx_icons[Math.min(6,model.confirmations)] + DelegateModel { + id: visualModel + model: Daemon.currentWallet.historyModel + + groups: [ + DelegateModelGroup { name: 'today'; includeByDefault: false }, + DelegateModelGroup { name: 'yesterday'; includeByDefault: false }, + DelegateModelGroup { name: 'lastweek'; includeByDefault: false }, + DelegateModelGroup { name: 'lastmonth'; includeByDefault: false }, + DelegateModelGroup { name: 'older'; includeByDefault: false } + ] + + delegate: Item { + id: delegate + width: ListView.view.width + height: delegateLayout.height + + ColumnLayout { + id: delegateLayout + width: parent.width + spacing: 0 + + ItemDelegate { + Layout.fillWidth: true + Layout.preferredHeight: txinfo.height + + GridLayout { + id: txinfo + columns: 3 + + x: constants.paddingSmall + width: delegate.width - 2*constants.paddingSmall + + Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1} + Image { + readonly property variant tx_icons : [ + "../../../gui/icons/unconfirmed.png", + "../../../gui/icons/clock1.png", + "../../../gui/icons/clock2.png", + "../../../gui/icons/clock3.png", + "../../../gui/icons/clock4.png", + "../../../gui/icons/clock5.png", + "../../../gui/icons/confirmed.png" + ] + + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + Layout.alignment: Qt.AlignVCenter + Layout.rowSpan: 2 + source: tx_icons[Math.min(6,model.confirmations)] + } + + Label { + font.pixelSize: constants.fontSizeLarge + Layout.fillWidth: true + text: model.label !== '' ? model.label : '' + color: model.label !== '' ? Material.accentColor : 'gray' + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Label { + id: valueLabel + font.family: FixedFont + font.pixelSize: constants.fontSizeMedium + Layout.alignment: Qt.AlignRight + text: Config.formatSats(model.bc_value) + font.bold: true + color: model.incoming ? constants.colorCredit : constants.colorDebit + } + Label { + font.pixelSize: constants.fontSizeSmall + text: model.date + } + Label { + font.pixelSize: constants.fontSizeXSmall + Layout.alignment: Qt.AlignRight + text: model.fee !== undefined ? 'fee: ' + model.fee : '' + } + Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } } + } - Label { - font.pixelSize: constants.fontSizeLarge - Layout.fillWidth: true - text: model.label !== '' ? model.label : '' - color: model.label !== '' ? Material.accentColor : 'gray' - wrapMode: Text.Wrap - maximumLineCount: 2 - elide: Text.ElideRight - } - Label { - id: valueLabel - font.family: FixedFont - font.pixelSize: constants.fontSizeMedium - text: Config.formatSats(model.bc_value) - font.bold: true - color: model.incoming ? constants.colorCredit : constants.colorDebit - } - Label { - font.pixelSize: constants.fontSizeSmall - text: model.date - } - Label { - font.pixelSize: constants.fontSizeXSmall - Layout.alignment: Qt.AlignRight - text: model.fee !== undefined ? 'fee: ' + model.fee : '' - } - Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } + Rectangle { + visible: delegate.ListView.section == delegate.ListView.nextSection + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: Qt.rgba(0,0,0,0.10) } + } - } - // as the items in the model are not bindings to QObjects, - // hook up events that might change the appearance - Connections { - target: Config - function onBaseUnitChanged() { - valueLabel.text = Config.formatSats(model.bc_value) + // as the items in the model are not bindings to QObjects, + // hook up events that might change the appearance + Connections { + target: Config + function onBaseUnitChanged() { + valueLabel.text = Config.formatSats(model.bc_value) + } + function onThousandsSeparatorChanged() { + valueLabel.text = Config.formatSats(model.bc_value) + } } - function onThousandsSeparatorChanged() { - valueLabel.text = Config.formatSats(model.bc_value) + + Component.onCompleted: { + if (model.section == 'today') { + delegate.DelegateModel.inToday = true + } else if (model.section == 'yesterday') { + delegate.DelegateModel.inYesterday = true + } else if (model.section == 'lastweek') { + delegate.DelegateModel.inLastweek = true + } else if (model.section == 'lastmonth') { + delegate.DelegateModel.inLastmonth = true + } else if (model.section == 'older') { + delegate.DelegateModel.inOlder = true + } } - } - } // delegate + } // delegate + } ScrollIndicator.vertical: ScrollIndicator { } diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 224824955..0e05b40c8 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex @@ -17,7 +17,7 @@ class QETransactionListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp', 'incoming','bc_value','bc_balance','date','label','txpos_in_block','fee', - 'inputs','outputs') + 'inputs','outputs','section') _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])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -43,14 +43,38 @@ class QETransactionListModel(QAbstractListModel): self.tx_history = [] self.endResetModel() + def tx_to_model(self, tx): + item = tx + for output in item['outputs']: + output['value'] = output['value'].value + + # newly arriving txs have no (block) timestamp + # TODO? + if not item['timestamp']: + item['timestamp'] = datetime.timestamp(datetime.now()) + + txts = datetime.fromtimestamp(item['timestamp']) + today = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) + + if (txts > today): + item['section'] = 'today' + elif (txts > today - timedelta(days=1)): + item['section'] = 'yesterday' + elif (txts > today - timedelta(days=7)): + item['section'] = 'lastweek' + elif (txts > today - timedelta(days=31)): + item['section'] = 'lastmonth' + else: + item['section'] = 'older' + + return item + # initial model data def init_model(self): history = self.wallet.get_detailed_history(show_addresses = True) - txs = history['transactions'] - # use primitives - for tx in txs: - for output in tx['outputs']: - output['value'] = output['value'].value + txs = [] + for tx in history['transactions']: + txs.append(self.tx_to_model(tx)) self.clear() self.beginInsertRows(QModelIndex(), 0, len(txs) - 1) From b2f2dfc44fb7bdb94ed2f902bc5fa144490f71fe Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Apr 2022 16:37:57 +0200 Subject: [PATCH 112/218] historic rates --- electrum/gui/qml/components/Preferences.qml | 23 ++++++---- electrum/gui/qml/qefx.py | 50 ++++++++++++++------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index a9de2ff7e..c1be7b0da 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -58,13 +58,19 @@ Pane { enabled: false } - Label { + CheckBox { + id: fiatEnable text: qsTr('Fiat Currency') + onCheckedChanged: { + if (activeFocus) + Daemon.fx.enabled = checked + } } ComboBox { id: currencies model: Daemon.fx.currencies + enabled: Daemon.fx.enabled onCurrentValueChanged: { if (activeFocus) Daemon.fx.fiatCurrency = currentValue @@ -72,24 +78,24 @@ Pane { } CheckBox { - id: historyRates - text: qsTr('History rates') - enabled: currencies.currentValue != '' + id: historicRates + text: qsTr('Historic rates') + enabled: Daemon.fx.enabled Layout.columnSpan: 2 onCheckStateChanged: { if (activeFocus) - Daemon.fx.historyRates = checked + Daemon.fx.historicRates = checked } } Label { text: qsTr('Source') - enabled: currencies.currentValue != '' + enabled: Daemon.fx.enabled } ComboBox { id: rateSources - enabled: currencies.currentValue != '' + enabled: Daemon.fx.enabled model: Daemon.fx.rateSources onModelChanged: { currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) @@ -109,7 +115,8 @@ Pane { baseUnit.currentIndex = ['BTC','mBTC','bits','sat'].indexOf(Config.baseUnit) thousands.checked = Config.thousandsSeparator currencies.currentIndex = currencies.indexOfValue(Daemon.fx.fiatCurrency) - historyRates.checked = Daemon.fx.historyRates + historicRates.checked = Daemon.fx.historicRates rateSources.currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) + fiatEnable.checked = Daemon.fx.enabled } } diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index 148484d1f..fd4cd6dd0 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -1,6 +1,7 @@ -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject - from decimal import Decimal +from datetime import datetime + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger from electrum.exchange_rate import FxThread @@ -31,12 +32,12 @@ class QEFX(QObject): currenciesChanged = pyqtSignal() @pyqtProperty('QVariantList', notify=currenciesChanged) def currencies(self): - return [''] + self.fx.get_currencies(self.historyRates) + return self.fx.get_currencies(self.historicRates) rateSourcesChanged = pyqtSignal() @pyqtProperty('QVariantList', notify=rateSourcesChanged) def rateSources(self): - return self.fx.get_exchanges_by_ccy(self.fiatCurrency, self.historyRates) + return self.fx.get_exchanges_by_ccy(self.fiatCurrency, self.historicRates) fiatCurrencyChanged = pyqtSignal() @pyqtProperty(str, notify=fiatCurrencyChanged) @@ -47,20 +48,20 @@ class QEFX(QObject): def fiatCurrency(self, currency): if currency != self.fiatCurrency: self.fx.set_currency(currency) - self.enabled = currency != '' + self.enabled = self.enabled and currency != '' self.fiatCurrencyChanged.emit() self.rateSourcesChanged.emit() - historyRatesChanged = pyqtSignal() - @pyqtProperty(bool, notify=historyRatesChanged) - def historyRates(self): + historicRatesChanged = pyqtSignal() + @pyqtProperty(bool, notify=historicRatesChanged) + def historicRates(self): return self.fx.get_history_config() - @historyRates.setter - def historyRates(self, checked): - if checked != self.historyRates: + @historicRates.setter + def historicRates(self, checked): + if checked != self.historicRates: self.fx.set_history_config(checked) - self.historyRatesChanged.emit() + self.historicRatesChanged.emit() self.rateSourcesChanged.emit() rateSourceChanged = pyqtSignal() @@ -74,8 +75,8 @@ class QEFX(QObject): self.fx.set_exchange(source) self.rateSourceChanged.emit() - enabledChanged = pyqtSignal() - @pyqtProperty(bool, notify=enabledChanged) + enabledUpdated = pyqtSignal() # curiously, enabledChanged is clashing, so name it enabledUpdated + @pyqtProperty(bool, notify=enabledUpdated) def enabled(self): return self.fx.is_enabled() @@ -83,7 +84,7 @@ class QEFX(QObject): def enabled(self, enable): if enable != self.enabled: self.fx.set_enabled(enable) - self.enabledChanged.emit() + self.enabledUpdated.emit() @pyqtSlot(str, result=str) @pyqtSlot(str, bool, result=str) @@ -98,7 +99,24 @@ class QEFX(QObject): if plain: return self.fx.ccy_amount_str(self.fx.fiat_value(satoshis, rate), False) else: - return self.fx.value_str(satoshis,rate) + return self.fx.value_str(satoshis, rate) + + @pyqtSlot(str, str, result=str) + def fiatValueHistoric(self, satoshis, timestamp, plain=True): + try: + sd = Decimal(satoshis) + if sd == 0: + return '' + td = Decimal(timestamp) + if td == 0: + return '' + except: + return '' + dt = datetime.fromtimestamp(td) + if plain: + return self.fx.ccy_amount_str(self.fx.historical_value(satoshis, dt), False) + else: + return self.fx.historical_value_str(satoshis, dt) @pyqtSlot(str, result=str) @pyqtSlot(str, bool, result=str) From 08db3190baefa587ac5e3add1008b2532c74e676 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Apr 2022 17:45:48 +0200 Subject: [PATCH 113/218] add android notification load both regular and bold fonts --- electrum/gui/qml/qeapp.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 0f209bfc7..671f79afe 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -1,10 +1,11 @@ import re import queue import time +import os from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl, QLocale, qInstallMessageHandler, QTimer from PyQt5.QtGui import QGuiApplication, QFontDatabase -from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine #, QQmlComponent +from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine from electrum.logging import Logger, get_logger from electrum import version @@ -18,6 +19,8 @@ from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin from .qefx import QEFX +notification = None + class QEAppController(QObject): userNotify = pyqtSignal(str) @@ -38,6 +41,8 @@ class QEAppController(QObject): self._qedaemon.walletLoaded.connect(self.on_wallet_loaded) + self.userNotify.connect(self.notifyAndroid) + def on_wallet_loaded(self): qewallet = self._qedaemon.currentWallet # attach to the wallet user notification events @@ -71,6 +76,18 @@ class QEAppController(QObject): except queue.Empty: pass + def notifyAndroid(self, message): + try: + # TODO: lazy load not in UI thread please + global notification + if not notification: + from plyer import notification + icon = (os.path.dirname(os.path.realpath(__file__)) + + '/../icons/electrum.png') + notification.notify('Electrum', message, app_icon=icon, app_name='Electrum') + except ImportError: + self.logger.error('Notification: needs plyer; `sudo python3 -m pip install plyer`') + @pyqtSlot('QString') def textToClipboard(self, text): QGuiApplication.clipboard().setText(text) @@ -101,8 +118,8 @@ class ElectrumQmlApplication(QGuiApplication): # add a monospace font as we can't rely on device having one self.fixedFont = 'PT Mono' - if QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Regular.ttf') < 0: - if QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Bold.ttf') < 0: + if (QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Regular.ttf') < 0 and + QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Bold.ttf') < 0): self.logger.warning('Could not load font PT Mono') self.fixedFont = 'Monospace' # hope for the best From 0e42744bc0fc3adaa5d71fc8bbd317239078e280 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Apr 2022 18:12:56 +0200 Subject: [PATCH 114/218] add request status code to listmodel, update request delegates, enable bip21 uri copy --- electrum/gui/qml/components/Receive.qml | 115 ++++++++++++------ electrum/gui/qml/components/RequestDialog.qml | 45 ++++--- electrum/gui/qml/qerequestlistmodel.py | 10 +- 3 files changed, 117 insertions(+), 53 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index a082a4ffc..20c74792b 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -129,6 +129,7 @@ Pane { Layout.columnSpan: 4 Layout.alignment: Qt.AlignHCenter text: qsTr('Create Request') + icon.source: '../../icons/qrcode.png' onClicked: { createRequest() } @@ -202,52 +203,90 @@ Pane { rightMargin: constants.paddingSmall } - columns: 5 + columns: 2 Rectangle { - Layout.columnSpan: 5 + Layout.columnSpan: 2 Layout.fillWidth: true Layout.preferredHeight: constants.paddingTiny color: 'transparent' } + Image { Layout.rowSpan: 2 Layout.preferredWidth: constants.iconSizeLarge Layout.preferredHeight: constants.iconSizeLarge source: model.type == 0 ? "../../icons/bitcoin.png" : "../../icons/lightning.png" } - Label { - Layout.fillWidth: true - Layout.columnSpan: 2 - text: model.message - elide: Text.ElideRight - font.pixelSize: constants.fontSizeLarge - } - Label { - text: qsTr('Amount: ') - } - Label { - id: amount - text: Config.formatSats(model.amount, true) - font.family: FixedFont + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.message ? model.message : model.address + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + font.pixelSize: model.message ? constants.fontSizeMedium : constants.fontSizeSmall + } + + Label { + id: amount + text: model.amount == 0 ? '' : Config.formatSats(model.amount) + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + } + + Label { + text: model.amount == 0 ? '' : Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } } - Label { - text: qsTr('Timestamp: ') - } - Label { - text: model.date + RowLayout { + Layout.fillWidth: true + Label { + text: model.status_str + color: Material.accentColor + } + Item { + Layout.fillWidth: true + Layout.preferredHeight: status_icon.height + Image { + id: status_icon + source: model.status == 0 + ? '../../icons/unpaid.png' + : model.status == 1 + ? '../../icons/expired.png' + : model.status == 3 + ? '../../icons/confirmed.png' + : model.status == 7 + ? '../../icons/unconfirmed.png' + : '' + width: constants.iconSizeSmall + height: constants.iconSizeSmall + } + } + Label { + id: fiatValue + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) + font.family: FixedFont + font.pixelSize: constants.fontSizeSmall + } + Label { + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount == 0 ? '' : Daemon.fx.fiatCurrency + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + } } - Label { - text: qsTr('Status: ') - } - Label { - text: model.status - } Rectangle { - Layout.columnSpan: 5 + Layout.columnSpan: 2 Layout.fillWidth: true Layout.preferredHeight: constants.paddingTiny color: 'transparent' @@ -257,10 +296,16 @@ Pane { Connections { target: Config function onBaseUnitChanged() { - amount.text = Config.formatSats(model.amount, true) + amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) } function onThousandsSeparatorChanged() { - amount.text = Config.formatSats(model.amount, true) + amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) + } + } + Connections { + target: Daemon.fx + function onQuotesUpdated() { + fiatValue.text = model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) } } @@ -269,11 +314,14 @@ Pane { } remove: Transition { - NumberAnimation { properties: 'scale'; to: 0; duration: 400 } + NumberAnimation { properties: 'scale'; to: 0.75; duration: 300 } NumberAnimation { properties: 'opacity'; to: 0; duration: 300 } } removeDisplaced: Transition { - SpringAnimation { properties: 'y'; duration: 100; spring: 5; damping: 0.5; mass: 2 } + SequentialAnimation { + PauseAnimation { duration: 200 } + SpringAnimation { properties: 'y'; duration: 100; spring: 5; damping: 0.5; mass: 2 } + } } ScrollIndicator.vertical: ScrollIndicator { } @@ -330,8 +378,7 @@ Pane { 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/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 29586ed6c..45f9b06dc 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -11,6 +11,8 @@ Dialog { property var modelItem + property string _bip21uri + parent: Overlay.overlay modal: true standardButtons: Dialog.Ok @@ -46,18 +48,18 @@ Dialog { id: rootLayout width: parent.width rowSpacing: constants.paddingMedium - columns: 4 + columns: 5 Rectangle { height: 1 Layout.fillWidth: true - Layout.columnSpan: 4 + Layout.columnSpan: 5 color: Material.accentColor } Image { id: qr - Layout.columnSpan: 4 + Layout.columnSpan: 5 Layout.alignment: Qt.AlignHCenter Layout.topMargin: constants.paddingSmall Layout.bottomMargin: constants.paddingSmall @@ -71,6 +73,7 @@ Dialog { height: size Image { + source: '../../icons/electrum.png' x: 1 y: 1 @@ -84,12 +87,12 @@ Dialog { Rectangle { height: 1 Layout.fillWidth: true - Layout.columnSpan: 4 + Layout.columnSpan: 5 color: Material.accentColor } RowLayout { - Layout.columnSpan: 4 + Layout.columnSpan: 5 Layout.alignment: Qt.AlignHCenter Button { icon.source: '../../icons/delete.png' @@ -103,7 +106,9 @@ Dialog { icon.source: '../../icons/copy_bw.png' icon.color: 'transparent' text: 'Copy' - enabled: false + onClicked: { + AppController.textToClipboard(_bip21uri) + } } Button { icon.source: '../../icons/share.png' @@ -117,9 +122,9 @@ Dialog { } Label { visible: modelItem.message != '' - Layout.columnSpan: 3 + Layout.columnSpan: 4 Layout.fillWidth: true - wrapMode: Text.WordWrap + wrapMode: Text.Wrap text: modelItem.message font.pixelSize: constants.fontSizeLarge } @@ -136,19 +141,29 @@ Dialog { } Label { visible: modelItem.amount > 0 - Layout.fillWidth: true - Layout.columnSpan: 2 text: Config.baseUnit color: Material.accentColor font.pixelSize: constants.fontSizeLarge } + Label { + id: fiatValue + visible: modelItem.amount > 0 + Layout.fillWidth: true + Layout.columnSpan: 2 + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(modelItem.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + font.pixelSize: constants.fontSizeMedium + wrapMode: Text.Wrap + } + Label { text: qsTr('Address') } Label { Layout.fillWidth: true - Layout.columnSpan: 2 + Layout.columnSpan: 3 font.family: FixedFont font.pixelSize: constants.fontSizeLarge wrapMode: Text.WrapAnywhere @@ -165,10 +180,10 @@ Dialog { text: qsTr('Status') } Label { - Layout.columnSpan: 3 + Layout.columnSpan: 4 Layout.fillWidth: true font.pixelSize: constants.fontSizeLarge - text: modelItem.status + text: modelItem.status_str } } @@ -184,8 +199,8 @@ Dialog { } Component.onCompleted: { - var bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.exp) - qr.source = 'image://qrgen/' + bip21uri + _bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.exp) + qr.source = 'image://qrgen/' + _bip21uri } Bitcoin { diff --git a/electrum/gui/qml/qerequestlistmodel.py b/electrum/gui/qml/qerequestlistmodel.py index 25ec63ca6..f6b129120 100644 --- a/electrum/gui/qml/qerequestlistmodel.py +++ b/electrum/gui/qml/qerequestlistmodel.py @@ -14,7 +14,7 @@ class QERequestListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES=('key','type','timestamp','date','message','amount','status','address','exp') + _ROLE_NAMES=('key','type','timestamp','date','message','amount','status','status_str','address','exp') _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])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -44,7 +44,8 @@ class QERequestListModel(QAbstractListModel): 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['status'] = status + item['status_str'] = req.get_status_str(status) item['type'] = req.type # 0=onchain, 2=LN item['timestamp'] = req.time item['date'] = format_time(item['timestamp']) @@ -97,7 +98,8 @@ class QERequestListModel(QAbstractListModel): for item in self.requests: if item['key'] == key: req = self.wallet.get_request(key) - item['status'] = req.get_status_str(status) + item['status'] = status + item['status_str'] = req.get_status_str(status) index = self.index(i,0) - self.dataChanged.emit(index, index, [self._ROLE_RMAP['status']]) + self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']]) i = i + 1 From 3b25f00041a9bd846dfcd1088b4c338b9e251077 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 11 Apr 2022 11:25:42 +0200 Subject: [PATCH 115/218] update History page delegates when rates or rate settings change --- electrum/gui/icons/confirmed_bw.png | Bin 0 -> 5113 bytes electrum/gui/qml/components/History.qml | 40 ++++++++++++++++++------ 2 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 electrum/gui/icons/confirmed_bw.png diff --git a/electrum/gui/icons/confirmed_bw.png b/electrum/gui/icons/confirmed_bw.png new file mode 100644 index 0000000000000000000000000000000000000000..ba66aa084911d4e7140d88c82e7c003e04dae2b0 GIT binary patch literal 5113 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 b9zFVhpyOgQ7`oGt00000NkvXXu0mjfDMYXo literal 0 HcmV?d00001 diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index e14e57848..8f1d05dca 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -82,7 +82,7 @@ Pane { "../../../gui/icons/clock3.png", "../../../gui/icons/clock4.png", "../../../gui/icons/clock5.png", - "../../../gui/icons/confirmed.png" + "../../../gui/icons/confirmed_bw.png" ] Layout.preferredWidth: constants.iconSizeLarge @@ -106,18 +106,34 @@ Pane { font.family: FixedFont font.pixelSize: constants.fontSizeMedium Layout.alignment: Qt.AlignRight - text: Config.formatSats(model.bc_value) font.bold: true color: model.incoming ? constants.colorCredit : constants.colorDebit + + function updateText() { + text = Config.formatSats(model.bc_value) + } + Component.onCompleted: updateText() } Label { font.pixelSize: constants.fontSizeSmall text: model.date } Label { - font.pixelSize: constants.fontSizeXSmall + id: fiatLabel + font.pixelSize: constants.fontSizeSmall Layout.alignment: Qt.AlignRight - text: model.fee !== undefined ? 'fee: ' + model.fee : '' + color: constants.mutedForeground + + function updateText() { + if (!Daemon.fx.enabled) { + text = '' + } else if (Daemon.fx.historicRates) { + text = Daemon.fx.fiatValueHistoric(model.bc_value, model.timestamp) + ' ' + Daemon.fx.fiatCurrency + } else { + text = Daemon.fx.fiatValue(model.bc_value, false) + ' ' + Daemon.fx.fiatCurrency + } + } + Component.onCompleted: updateText() } Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } } @@ -135,12 +151,16 @@ Pane { // hook up events that might change the appearance Connections { target: Config - function onBaseUnitChanged() { - valueLabel.text = Config.formatSats(model.bc_value) - } - function onThousandsSeparatorChanged() { - valueLabel.text = Config.formatSats(model.bc_value) - } + function onBaseUnitChanged() { valueLabel.updateText() } + function onThousandsSeparatorChanged() { valueLabel.updateText() } + } + + Connections { + target: Daemon.fx + function onHistoricRatesChanged() { fiatLabel.updateText() } + function onQuotesUpdated() { fiatLabel.updateText() } + function onHistoryUpdated() { fiatLabel.updateText() } + function onEnabledUpdated() { fiatLabel.updateText() } } Component.onCompleted: { From 34ef93b2b584b9ae733b3f413e9453cc2ba2d840 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 12 Apr 2022 16:48:32 +0200 Subject: [PATCH 116/218] add confirm payment dialog/feepicker and qobject backing --- electrum/gui/icons/paste.png | Bin 0 -> 1309 bytes .../qml/components/ConfirmPaymentDialog.qml | 195 +++++++++++++++ electrum/gui/qml/components/Send.qml | 120 +++++++-- electrum/gui/qml/qeapp.py | 6 + electrum/gui/qml/qetxfinalizer.py | 228 ++++++++++++++++++ 5 files changed, 523 insertions(+), 26 deletions(-) create mode 100644 electrum/gui/icons/paste.png create mode 100644 electrum/gui/qml/components/ConfirmPaymentDialog.qml create mode 100644 electrum/gui/qml/qetxfinalizer.py diff --git a/electrum/gui/icons/paste.png b/electrum/gui/icons/paste.png new file mode 100644 index 0000000000000000000000000000000000000000..e70bb37f9759eb6093c41b80e0b6ff4ab37f950b GIT binary patch literal 1309 zcmV+&1>*XNP)7??yYfXc=G+XUTEBba>@6p-J%gB+)-nBXpPej85I71%(m+(6n%&veCE>r>~|;a{d`(-b=+}fPN2@N*80Y-J}r0cJjd>p10LKSd8Pkr-PJ@vC>HW z9$;)fO)=mzc33u>kF_#tFsiX?7~FFCIDXet@iw4$pqDlg!`?XBTWoX^`V<Iv~^+TS-csFeo%^heqcUhqj9mr691U7JJ z?=Q+}goR9-7B9KHjeICO_Itp51yC*#rOpxy(K%WOvA72d!(-P@+ljZNZ5)_Ij$Vq4 zbkYZJ1GWHcA;R{NI6x^xByd#jTu2x@fysAIllzD<$&X?$FWexrVYv{k+lN{D24L*0-{ujMb*KQhuw(; z7V>Rc{}ONgn5U5I`kw|LDlNF~xl~N4Fg)hir_k6tV4gzRomijuC<57?SRbP7POOhl zzo@vEO<5dcRDR^$z6ogC5XK=FlzE3fyWp(9O|1K=hJ4&;M+=e~ 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() From 6a22a7698c5a3d067963b3322e96a3deffde2135 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 12 Apr 2022 16:50:27 +0200 Subject: [PATCH 117/218] various UI --- electrum/gui/qml/components/controls/InfoTextArea.qml | 1 + electrum/gui/qml/components/main.qml | 1 + electrum/gui/qml/qefx.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/controls/InfoTextArea.qml b/electrum/gui/qml/components/controls/InfoTextArea.qml index ec55c9527..ba2848aac 100644 --- a/electrum/gui/qml/components/controls/InfoTextArea.qml +++ b/electrum/gui/qml/components/controls/InfoTextArea.qml @@ -27,6 +27,7 @@ GridLayout { TextArea { id: infotext Layout.fillWidth: true + Layout.minimumHeight: constants.iconSizeLarge + 2*constants.paddingLarge readOnly: true rightPadding: constants.paddingLarge leftPadding: 2*constants.iconSizeLarge diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 576c5c48d..40f73b3b7 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -20,6 +20,7 @@ ApplicationWindow Material.theme: Material.Dark Material.primary: Material.Indigo Material.accent: Material.LightBlue + font.pixelSize: constants.fontSizeMedium property Item constants: appconstants Constants { id: appconstants } diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index fd4cd6dd0..abe2f87f6 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -112,7 +112,7 @@ class QEFX(QObject): return '' except: return '' - dt = datetime.fromtimestamp(td) + dt = datetime.fromtimestamp(int(td)) if plain: return self.fx.ccy_amount_str(self.fx.historical_value(satoshis, dt), False) else: From e8ce221a34f5fdfe9a94c96e0eb00f60351a7723 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 14 Apr 2022 11:20:00 +0200 Subject: [PATCH 118/218] Qt.UserRole can be 0 offset, don't repeat wallet create request dict --- electrum/gui/qml/components/RequestDialog.qml | 2 +- electrum/gui/qml/qeaddresslistmodel.py | 4 ++-- electrum/gui/qml/qedaemon.py | 4 ++-- electrum/gui/qml/qerequestlistmodel.py | 21 +++++-------------- electrum/gui/qml/qetransactionlistmodel.py | 4 ++-- 5 files changed, 12 insertions(+), 23 deletions(-) diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 45f9b06dc..614595a5f 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -199,7 +199,7 @@ Dialog { } Component.onCompleted: { - _bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.exp) + _bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration) qr.source = 'image://qrgen/' + _bip21uri } diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index 5549c87b4..e32caf180 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -16,7 +16,7 @@ class QEAddressListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('type','iaddr','address','label','balance','numtx', 'held') - _ROLE_KEYS = range(Qt.UserRole + 1, Qt.UserRole + 1 + len(_ROLE_NAMES)) + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) def rowCount(self, index): @@ -30,7 +30,7 @@ class QEAddressListModel(QAbstractListModel): address = self.change_addresses[index.row() - len(self.receive_addresses)] else: address = self.receive_addresses[index.row()] - role_index = role - (Qt.UserRole + 1) + role_index = role - Qt.UserRole value = address[self._ROLE_NAMES[role_index]] if isinstance(value, bool) or isinstance(value, list) or isinstance(value, int) or value is None: return value diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index c8c66191f..113baf938 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -22,7 +22,7 @@ class QEWalletListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES= ('name','path','active') - _ROLE_KEYS = range(Qt.UserRole + 1, Qt.UserRole + 1 + len(_ROLE_NAMES)) + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) def rowCount(self, index): @@ -33,7 +33,7 @@ class QEWalletListModel(QAbstractListModel): def data(self, index, role): (wallet_name, wallet_path, wallet) = self.wallets[index.row()] - role_index = role - (Qt.UserRole + 1) + role_index = role - Qt.UserRole role_name = self._ROLE_NAMES[role_index] if role_name == 'name': return wallet_name diff --git a/electrum/gui/qml/qerequestlistmodel.py b/electrum/gui/qml/qerequestlistmodel.py index f6b129120..a664a106c 100644 --- a/electrum/gui/qml/qerequestlistmodel.py +++ b/electrum/gui/qml/qerequestlistmodel.py @@ -14,8 +14,8 @@ class QERequestListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES=('key','type','timestamp','date','message','amount','status','status_str','address','exp') - _ROLE_KEYS = range(Qt.UserRole + 1, Qt.UserRole + 1 + len(_ROLE_NAMES)) + _ROLE_NAMES=('key','type','timestamp','date','message','amount','status','status_str','address','expiration') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -27,7 +27,7 @@ class QERequestListModel(QAbstractListModel): def data(self, index, role): request = self.requests[index.row()] - role_index = role - (Qt.UserRole + 1) + role_index = role - Qt.UserRole 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 @@ -41,22 +41,11 @@ class QERequestListModel(QAbstractListModel): 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'] = status - item['status_str'] = req.get_status_str(status) + item = self.wallet.export_request(req) + item['key'] = self.wallet.get_key_for_receive_request(req) item['type'] = req.type # 0=onchain, 2=LN - item['timestamp'] = req.time item['date'] = format_time(item['timestamp']) item['amount'] = req.get_amount_sat() - item['message'] = req.message - item['exp'] = req.exp - if req.type == 0: # OnchainInvoice - item['key'] = item['address'] = req.get_address() - elif req.type == 2: # LNInvoice - #item['key'] = req.getrhash() - pass return item diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 0e05b40c8..453e1a85b 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -18,7 +18,7 @@ class QETransactionListModel(QAbstractListModel): _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp', 'incoming','bc_value','bc_balance','date','label','txpos_in_block','fee', 'inputs','outputs','section') - _ROLE_KEYS = range(Qt.UserRole + 1, Qt.UserRole + 1 + len(_ROLE_NAMES)) + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -30,7 +30,7 @@ class QETransactionListModel(QAbstractListModel): def data(self, index, role): tx = self.tx_history[index.row()] - role_index = role - (Qt.UserRole + 1) + role_index = role - Qt.UserRole value = tx[self._ROLE_NAMES[role_index]] if isinstance(value, bool) or isinstance(value, list) or isinstance(value, int) or value is None: return value From 06aed727ef8a6a11ec9565b68d18599820b69555 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 14 Apr 2022 12:19:25 +0200 Subject: [PATCH 119/218] add listmodel for send queue/invoices generalize request and invoice list models into abstract base --- electrum/gui/qml/components/Send.qml | 150 ++++++++++++++++++++++++- electrum/gui/qml/qeinvoicelistmodel.py | 148 ++++++++++++++++++++++++ electrum/gui/qml/qerequestlistmodel.py | 94 ---------------- electrum/gui/qml/qewallet.py | 9 +- 4 files changed, 301 insertions(+), 100 deletions(-) create mode 100644 electrum/gui/qml/qeinvoicelistmodel.py delete mode 100644 electrum/gui/qml/qerequestlistmodel.py diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 7a81cf085..f97e048eb 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -2,6 +2,7 @@ import QtQuick 2.6 import QtQuick.Controls 2.0 import QtQuick.Layouts 1.0 import QtQuick.Controls.Material 2.0 +import QtQml.Models 2.1 import "controls" @@ -200,15 +201,154 @@ Pane { Layout.fillHeight: true Layout.fillWidth: true clip: true + + model: DelegateModel { + id: delegateModel + model: Daemon.currentWallet.invoiceModel + + delegate: ItemDelegate { + id: root + height: item.height + width: ListView.view.width + + font.pixelSize: constants.fontSizeSmall // set default font size for child controls + + GridLayout { + id: item + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingSmall + rightMargin: constants.paddingSmall + } + + columns: 2 + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + + Image { + Layout.rowSpan: 2 + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + source: model.type == 0 ? "../../icons/bitcoin.png" : "../../icons/lightning.png" + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.message ? model.message : model.address + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + font.pixelSize: model.message ? constants.fontSizeMedium : constants.fontSizeSmall + } + + Label { + id: amount + text: model.amount == 0 ? '' : Config.formatSats(model.amount) + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + } + + Label { + text: model.amount == 0 ? '' : Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } + } + + RowLayout { + Layout.fillWidth: true + Label { + text: model.status_str + color: Material.accentColor + } + Item { + Layout.fillWidth: true + Layout.preferredHeight: status_icon.height + Image { + id: status_icon + source: model.status == 0 + ? '../../icons/unpaid.png' + : model.status == 1 + ? '../../icons/expired.png' + : model.status == 3 + ? '../../icons/confirmed.png' + : model.status == 7 + ? '../../icons/unconfirmed.png' + : '' + width: constants.iconSizeSmall + height: constants.iconSizeSmall + } + } + Label { + id: fiatValue + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) + font.family: FixedFont + font.pixelSize: constants.fontSizeSmall + } + Label { + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount == 0 ? '' : Daemon.fx.fiatCurrency + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + } + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) + } + function onThousandsSeparatorChanged() { + amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) + } + } + Connections { + target: Daemon.fx + function onQuotesUpdated() { + fiatValue.text = model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) + } + } + + } + + } + + remove: Transition { + NumberAnimation { properties: 'scale'; to: 0.75; duration: 300 } + NumberAnimation { properties: 'opacity'; to: 0; duration: 300 } + } + removeDisplaced: Transition { + SequentialAnimation { + PauseAnimation { duration: 200 } + SpringAnimation { properties: 'y'; duration: 100; spring: 5; damping: 0.5; mass: 2 } + } + } + + ScrollIndicator.vertical: ScrollIndicator { } } } } - Component { - id: confirmPaymentDialog - ConfirmPaymentDialog {} - } - Connections { target: Daemon.fx function onQuotesUpdated() { diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py new file mode 100644 index 000000000..596949894 --- /dev/null +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -0,0 +1,148 @@ +from abc import abstractmethod + +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 QEAbstractInvoiceListModel(QAbstractListModel): + _logger = get_logger(__name__) + + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.invoices = [] + + # define listmodel rolemap + _ROLE_NAMES=('key','type','timestamp','date','message','amount','status','status_str','address','expiration') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) + + def rowCount(self, index): + return len(self.invoices) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + invoice = self.invoices[index.row()] + role_index = role - Qt.UserRole + value = invoice[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.invoices = [] + self.endResetModel() + + @pyqtSlot() + def init_model(self): + invoices = [] + for invoice in self.get_invoice_list(): + item = self.invoice_to_model(invoice) + self._logger.debug(str(item)) + invoices.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, len(self.invoices) - 1) + self.invoices = invoices + self.endInsertRows() + + def add_invoice(self, invoice: Invoice): + item = self.invoice_to_model(invoice) + self._logger.debug(str(item)) + + self.beginInsertRows(QModelIndex(), 0, 0) + self.invoices.insert(0, item) + self.endInsertRows() + + def delete_invoice(self, key: str): + i = 0 + for invoice in self.invoices: + if invoice['key'] == key: + self.beginRemoveRows(QModelIndex(), i, i) + self.invoices.pop(i) + self.endRemoveRows() + break + i = i + 1 + + @pyqtSlot(str, int) + def updateInvoice(self, key, status): + self._logger.debug('updating invoice for %s to %d' % (key,status)) + i = 0 + for item in self.invoices: + if item['key'] == key: + invoice = self.get_invoice_for_key(key) #self.wallet.get_invoice(key) + item['status'] = status + item['status_str'] = invoice.get_status_str(status) + index = self.index(i,0) + self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']]) + i = i + 1 + + @abstractmethod + def get_invoice_for_key(self, key: str): + raise Exception('provide impl') + + @abstractmethod + def get_invoice_list(self): + raise Exception('provide impl') + + @abstractmethod + def invoice_to_model(self, invoice: Invoice): + raise Exception('provide impl') + +class QEInvoiceListModel(QEAbstractInvoiceListModel): + def __init__(self, wallet, parent=None): + super().__init__(wallet, parent) + + _logger = get_logger(__name__) + + def get_invoice_list(self): + return self.wallet.get_unpaid_invoices() + + def invoice_to_model(self, invoice: Invoice): + item = self.wallet.export_invoice(invoice) + item['type'] = invoice.type # 0=onchain, 2=LN + item['date'] = format_time(item['timestamp']) + item['amount'] = invoice.get_amount_sat() + if invoice.type == 0: + item['key'] = invoice.id + elif invoice.type == 2: + item['key'] = invoice.rhash + + return item + + def get_invoice_for_key(self, key: str): + return self.wallet.get_invoice(key) + +class QERequestListModel(QEAbstractInvoiceListModel): + def __init__(self, wallet, parent=None): + super().__init__(wallet, parent) + + _logger = get_logger(__name__) + + def get_invoice_list(self): + return self.wallet.get_unpaid_requests() + + def invoice_to_model(self, req: Invoice): + item = self.wallet.export_request(req) + item['key'] = self.wallet.get_key_for_receive_request(req) + item['type'] = req.type # 0=onchain, 2=LN + item['date'] = format_time(item['timestamp']) + item['amount'] = req.get_amount_sat() + + return item + + def get_invoice_for_key(self, key: str): + return self.wallet.get_request(key) + + @pyqtSlot(str, int) + def updateRequest(self, key, status): + self.updateInvoice(key, status) diff --git a/electrum/gui/qml/qerequestlistmodel.py b/electrum/gui/qml/qerequestlistmodel.py deleted file mode 100644 index a664a106c..000000000 --- a/electrum/gui/qml/qerequestlistmodel.py +++ /dev/null @@ -1,94 +0,0 @@ -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=('key','type','timestamp','date','message','amount','status','status_str','address','expiration') - _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) - _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) - _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) - - 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 - 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 = self.wallet.export_request(req) - item['key'] = self.wallet.get_key_for_receive_request(req) - item['type'] = req.type # 0=onchain, 2=LN - item['date'] = format_time(item['timestamp']) - item['amount'] = req.get_amount_sat() - - 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() - - def delete_request(self, key: str): - i = 0 - for request in self.requests: - if request['key'] == key: - self.beginRemoveRows(QModelIndex(), i, i) - self.requests.pop(i) - self.endRemoveRows() - break - i = i + 1 - - @pyqtSlot(str, int) - def updateRequest(self, key, status): - self._logger.debug('updating request for %s to %d' % (key,status)) - i = 0 - for item in self.requests: - if item['key'] == key: - req = self.wallet.get_request(key) - item['status'] = status - item['status_str'] = req.get_status_str(status) - index = self.index(i,0) - self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']]) - i = i + 1 diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 73541df6d..e77e2c726 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -13,7 +13,7 @@ from electrum.transaction import PartialTxOutput 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 +from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel @@ -54,9 +54,11 @@ class QEWallet(QObject): self._historyModel = QETransactionListModel(wallet) self._addressModel = QEAddressListModel(wallet) self._requestModel = QERequestListModel(wallet) + self._invoiceModel = QEInvoiceListModel(wallet) self._historyModel.init_model() self._requestModel.init_model() + self._invoiceModel.init_model() self.tx_notification_queue = queue.Queue() self.tx_notification_last_time = 0 @@ -175,6 +177,11 @@ class QEWallet(QObject): def requestModel(self): return self._requestModel + invoiceModelChanged = pyqtSignal() + @pyqtProperty(QEInvoiceListModel, notify=invoiceModelChanged) + def invoiceModel(self): + return self._invoiceModel + nameChanged = pyqtSignal() @pyqtProperty('QString', notify=nameChanged) def name(self): From 3aef04f82474054a47a2e649bde11b6a0ac051fa Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 14 Apr 2022 12:21:11 +0200 Subject: [PATCH 120/218] factor out InvoiceDelegate --- electrum/gui/qml/components/Receive.qml | 127 +---------------- electrum/gui/qml/components/Send.qml | 127 +---------------- .../components/controls/InvoiceDelegate.qml | 129 ++++++++++++++++++ 3 files changed, 131 insertions(+), 252 deletions(-) create mode 100644 electrum/gui/qml/components/controls/InvoiceDelegate.qml diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 20c74792b..ddf2c00b8 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -180,137 +180,12 @@ Pane { model: DelegateModel { id: delegateModel model: Daemon.currentWallet.requestModel - - delegate: ItemDelegate { - id: root - height: item.height - width: ListView.view.width - + delegate: InvoiceDelegate { onClicked: { var dialog = requestdialog.createObject(app, {'modelItem': model}) dialog.open() } - - font.pixelSize: constants.fontSizeSmall // set default font size for child controls - - GridLayout { - id: item - - anchors { - left: parent.left - right: parent.right - leftMargin: constants.paddingSmall - rightMargin: constants.paddingSmall - } - - columns: 2 - - Rectangle { - Layout.columnSpan: 2 - Layout.fillWidth: true - Layout.preferredHeight: constants.paddingTiny - color: 'transparent' - } - - Image { - Layout.rowSpan: 2 - Layout.preferredWidth: constants.iconSizeLarge - Layout.preferredHeight: constants.iconSizeLarge - source: model.type == 0 ? "../../icons/bitcoin.png" : "../../icons/lightning.png" - } - - RowLayout { - Layout.fillWidth: true - Label { - Layout.fillWidth: true - text: model.message ? model.message : model.address - elide: Text.ElideRight - wrapMode: Text.Wrap - maximumLineCount: 2 - font.pixelSize: model.message ? constants.fontSizeMedium : constants.fontSizeSmall - } - - Label { - id: amount - text: model.amount == 0 ? '' : Config.formatSats(model.amount) - font.pixelSize: constants.fontSizeMedium - font.family: FixedFont - } - - Label { - text: model.amount == 0 ? '' : Config.baseUnit - font.pixelSize: constants.fontSizeMedium - color: Material.accentColor - } - } - - RowLayout { - Layout.fillWidth: true - Label { - text: model.status_str - color: Material.accentColor - } - Item { - Layout.fillWidth: true - Layout.preferredHeight: status_icon.height - Image { - id: status_icon - source: model.status == 0 - ? '../../icons/unpaid.png' - : model.status == 1 - ? '../../icons/expired.png' - : model.status == 3 - ? '../../icons/confirmed.png' - : model.status == 7 - ? '../../icons/unconfirmed.png' - : '' - width: constants.iconSizeSmall - height: constants.iconSizeSmall - } - } - Label { - id: fiatValue - visible: Daemon.fx.enabled - Layout.alignment: Qt.AlignRight - text: model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) - font.family: FixedFont - font.pixelSize: constants.fontSizeSmall - } - Label { - visible: Daemon.fx.enabled - Layout.alignment: Qt.AlignRight - text: model.amount == 0 ? '' : Daemon.fx.fiatCurrency - font.pixelSize: constants.fontSizeSmall - color: Material.accentColor - } - } - - Rectangle { - Layout.columnSpan: 2 - Layout.fillWidth: true - Layout.preferredHeight: constants.paddingTiny - color: 'transparent' - } - } - - Connections { - target: Config - function onBaseUnitChanged() { - amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) - } - function onThousandsSeparatorChanged() { - amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) - } - } - Connections { - target: Daemon.fx - function onQuotesUpdated() { - fiatValue.text = model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) - } - } - } - } remove: Transition { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index f97e048eb..1751fbb03 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -205,132 +205,7 @@ Pane { model: DelegateModel { id: delegateModel model: Daemon.currentWallet.invoiceModel - - delegate: ItemDelegate { - id: root - height: item.height - width: ListView.view.width - - font.pixelSize: constants.fontSizeSmall // set default font size for child controls - - GridLayout { - id: item - - anchors { - left: parent.left - right: parent.right - leftMargin: constants.paddingSmall - rightMargin: constants.paddingSmall - } - - columns: 2 - - Rectangle { - Layout.columnSpan: 2 - Layout.fillWidth: true - Layout.preferredHeight: constants.paddingTiny - color: 'transparent' - } - - Image { - Layout.rowSpan: 2 - Layout.preferredWidth: constants.iconSizeLarge - Layout.preferredHeight: constants.iconSizeLarge - source: model.type == 0 ? "../../icons/bitcoin.png" : "../../icons/lightning.png" - } - - RowLayout { - Layout.fillWidth: true - Label { - Layout.fillWidth: true - text: model.message ? model.message : model.address - elide: Text.ElideRight - wrapMode: Text.Wrap - maximumLineCount: 2 - font.pixelSize: model.message ? constants.fontSizeMedium : constants.fontSizeSmall - } - - Label { - id: amount - text: model.amount == 0 ? '' : Config.formatSats(model.amount) - font.pixelSize: constants.fontSizeMedium - font.family: FixedFont - } - - Label { - text: model.amount == 0 ? '' : Config.baseUnit - font.pixelSize: constants.fontSizeMedium - color: Material.accentColor - } - } - - RowLayout { - Layout.fillWidth: true - Label { - text: model.status_str - color: Material.accentColor - } - Item { - Layout.fillWidth: true - Layout.preferredHeight: status_icon.height - Image { - id: status_icon - source: model.status == 0 - ? '../../icons/unpaid.png' - : model.status == 1 - ? '../../icons/expired.png' - : model.status == 3 - ? '../../icons/confirmed.png' - : model.status == 7 - ? '../../icons/unconfirmed.png' - : '' - width: constants.iconSizeSmall - height: constants.iconSizeSmall - } - } - Label { - id: fiatValue - visible: Daemon.fx.enabled - Layout.alignment: Qt.AlignRight - text: model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) - font.family: FixedFont - font.pixelSize: constants.fontSizeSmall - } - Label { - visible: Daemon.fx.enabled - Layout.alignment: Qt.AlignRight - text: model.amount == 0 ? '' : Daemon.fx.fiatCurrency - font.pixelSize: constants.fontSizeSmall - color: Material.accentColor - } - } - - Rectangle { - Layout.columnSpan: 2 - Layout.fillWidth: true - Layout.preferredHeight: constants.paddingTiny - color: 'transparent' - } - } - - Connections { - target: Config - function onBaseUnitChanged() { - amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) - } - function onThousandsSeparatorChanged() { - amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) - } - } - Connections { - target: Daemon.fx - function onQuotesUpdated() { - fiatValue.text = model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) - } - } - - } - + delegate: InvoiceDelegate {} } remove: Transition { diff --git a/electrum/gui/qml/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml new file mode 100644 index 000000000..00b14bf2a --- /dev/null +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -0,0 +1,129 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +ItemDelegate { + id: root + height: item.height + width: ListView.view.width + + font.pixelSize: constants.fontSizeSmall // set default font size for child controls + + GridLayout { + id: item + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingSmall + rightMargin: constants.paddingSmall + } + + columns: 2 + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + + Image { + Layout.rowSpan: 2 + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + source: model.type == 0 ? "../../../icons/bitcoin.png" : "../../../icons/lightning.png" + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.message ? model.message : model.address + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + font.pixelSize: model.message ? constants.fontSizeMedium : constants.fontSizeSmall + } + + Label { + id: amount + text: model.amount == 0 ? '' : Config.formatSats(model.amount) + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + } + + Label { + text: model.amount == 0 ? '' : Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } + } + + RowLayout { + Layout.fillWidth: true + Label { + text: model.status_str + color: Material.accentColor + } + Item { + Layout.fillWidth: true + Layout.preferredHeight: status_icon.height + Image { + id: status_icon + source: model.status == 0 + ? '../../../icons/unpaid.png' + : model.status == 1 + ? '../../../icons/expired.png' + : model.status == 3 + ? '../../../icons/confirmed.png' + : model.status == 7 + ? '../../../icons/unconfirmed.png' + : '' + width: constants.iconSizeSmall + height: constants.iconSizeSmall + } + } + Label { + id: fiatValue + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) + font.family: FixedFont + font.pixelSize: constants.fontSizeSmall + } + Label { + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount == 0 ? '' : Daemon.fx.fiatCurrency + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + } + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) + } + function onThousandsSeparatorChanged() { + amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) + } + } + Connections { + target: Daemon.fx + function onQuotesUpdated() { + fiatValue.text = model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) + } + } + +} From 8a3aff73fcb9dcca3f992eed0a410078a854dc4a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 14 Apr 2022 12:27:15 +0200 Subject: [PATCH 121/218] fix qewallet calls to invoice list model --- electrum/gui/qml/qewallet.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index e77e2c726..7413e4c20 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -307,7 +307,7 @@ class QEWallet(QObject): # TODO: check this flow. Only if alias is defined in config. OpenAlias? pass #self.sign_payment_request(addr) - self._requestModel.add_request(req) + self._requestModel.add_invoice(req) return addr @pyqtSlot('quint64', 'QString', int) @@ -337,9 +337,19 @@ class QEWallet(QObject): @pyqtSlot('QString') def delete_request(self, key: str): self.wallet.delete_request(key) - self._requestModel.delete_request(key) + self._requestModel.delete_invoice(key) @pyqtSlot('QString', result='QVariant') def get_request(self, key: str): req = self.wallet.get_request(key) - return self._requestModel.request_to_model(req) + return self._requestModel.invoice_to_model(req) + + @pyqtSlot('QString') + def delete_invoice(self, key: str): + self.wallet.delete_invoice(key) + self._invoiceModel.delete_invoice(key) + + @pyqtSlot('QString', result='QVariant') + def get_invoice(self, key: str): + req = self.wallet.get_invoice(key) + return self._invoiceModel.invoice_to_model(req) From cd4bd3958313fd66eef2568b012e6d60a3f524d8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 22 Apr 2022 12:16:57 +0200 Subject: [PATCH 122/218] wip --- .../qml/components/ConfirmInvoiceDialog.qml | 118 +++++++++ electrum/gui/qml/components/Send.qml | 91 ++++++- .../gui/qml/components/WalletMainView.qml | 19 +- electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qeinvoice.py | 235 ++++++++++++++++++ electrum/gui/qml/qeinvoicelistmodel.py | 4 +- electrum/gui/qml/qeqr.py | 3 +- electrum/gui/qml/qewallet.py | 18 +- 8 files changed, 464 insertions(+), 26 deletions(-) create mode 100644 electrum/gui/qml/components/ConfirmInvoiceDialog.qml create mode 100644 electrum/gui/qml/qeinvoice.py diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml new file mode 100644 index 000000000..7ebeeaeb0 --- /dev/null +++ b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml @@ -0,0 +1,118 @@ +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 Invoice invoice + + width: parent.width + height: parent.height + + title: qsTr('Invoice') + + 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('Type') + } + + Label { + text: invoice.invoiceType == Invoice.OnchainInvoice + ? qsTr('On-chain invoice') + : invoice.invoiceType == Invoice.LightningInvoice + ? qsTr('Lightning invoice') + : '' + Layout.fillWidth: true + } + + Label { + text: qsTr('Description') + } + + Label { + text: invoice.message + Layout.fillWidth: true + } + + Label { + text: qsTr('Amount to send') + } + + RowLayout { + Layout.fillWidth: true + Label { + font.bold: true + text: Config.formatSats(invoice.amount, false) + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + + Label { + id: fiatValue + Layout.fillWidth: true + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(invoice.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + font.pixelSize: constants.fontSizeMedium + } + } + + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + spacing: constants.paddingMedium + + Button { + text: qsTr('Cancel') + onClicked: dialog.close() + } + + Button { + text: qsTr('Save') +// enabled: invoice.invoiceType != Invoice.Invalid + enabled: invoice.invoiceType == Invoice.OnchainInvoice + onClicked: { + invoice.save_invoice() + dialog.close() + } + } + + Button { + text: qsTr('Pay now') + enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds + onClicked: { + console.log('pay now') + } + } + } + + } + +} diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 1751fbb03..5a464f579 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -4,11 +4,18 @@ import QtQuick.Layouts 1.0 import QtQuick.Controls.Material 2.0 import QtQml.Models 2.1 +import org.electrum 1.0 + import "controls" Pane { id: rootItem + function clear() { + recipient.text = '' + amount.text = '' + } + GridLayout { id: form width: parent.width @@ -26,12 +33,16 @@ Pane { } TextArea { - id: address + id: recipient Layout.columnSpan: 2 Layout.fillWidth: true font.family: FixedFont wrapMode: Text.Wrap placeholderText: qsTr('Paste address or invoice') + onTextChanged: { + if (activeFocus) + invoice.recipient = text + } } RowLayout { @@ -40,7 +51,7 @@ Pane { icon.source: '../../icons/paste.png' icon.height: constants.iconSizeMedium icon.width: constants.iconSizeMedium - onClicked: address.text = AppController.clipboardToText() + onClicked: invoice.recipient = AppController.clipboardToText() } ToolButton { icon.source: '../../icons/qrcode.png' @@ -50,10 +61,7 @@ Pane { 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'] + invoice.recipient = page.scanData }) } } @@ -122,9 +130,9 @@ Pane { } TextField { - id: description + id: message font.family: FixedFont - placeholderText: qsTr('Description') + placeholderText: qsTr('Message') Layout.columnSpan: 3 Layout.fillWidth: true } @@ -136,24 +144,24 @@ Pane { Button { text: qsTr('Save') - enabled: false + enabled: invoice.invoiceType != Invoice.Invalid onClicked: { - console.log('TODO: save') + Daemon.currentWallet.create_invoice(recipient.text, amount.text, message.text) } } Button { text: qsTr('Pay now') - enabled: amount.text != '' && address.text != ''// TODO proper validation + enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds onClicked: { var f_amount = parseFloat(amount.text) if (isNaN(f_amount)) return var sats = Config.unitsToSats(amount.text).toString() var dialog = confirmPaymentDialog.createObject(app, { - 'address': address.text, + 'address': recipient.text, 'satoshis': sats, - 'message': description.text + 'message': message.text }) dialog.open() } @@ -224,6 +232,24 @@ Pane { } } + Component { + id: confirmPaymentDialog + ConfirmPaymentDialog {} + } + + Component { + id: confirmInvoiceDialog + ConfirmInvoiceDialog {} + } + + Connections { + target: Daemon.currentWallet + function onInvoiceStatusChanged(key, status) { + // TODO: status from? + //Daemon.currentWallet.invoiceModel.updateInvoice(key, status) + } + } + Connections { target: Daemon.fx function onQuotesUpdated() { @@ -240,4 +266,43 @@ Pane { FocusScope { id: parkFocus } } + Invoice { + id: invoice + wallet: Daemon.currentWallet + onValidationError: { + if (recipient.activeFocus) { + // no popups when editing + return + } + console.log(code + ' ' + message) + + var dialog = app.messageDialog.createObject(app, {'text': message }) + dialog.open() + rootItem.clear() + } + onValidationWarning: { + if (code == 'no_channels') { + var dialog = app.messageDialog.createObject(app, {'text': message }) + dialog.open() + // TODO: ask user to open a channel, if funds allow + // and maybe store invoice if expiry allows + } + } + onInvoiceTypeChanged: { + if (invoiceType == Invoice.Invalid) + return + // address only -> fill form fields + // else -> show invoice confirmation dialog + if (invoiceType == Invoice.OnchainOnlyAddress) + recipient.text = invoice.recipient + else { + var dialog = confirmInvoiceDialog.createObject(rootItem, {'invoice': invoice}) + dialog.open() + } + } + onInvoiceSaved: { + console.log('invoice got saved') + Daemon.currentWallet.invoiceModel.init_model() + } + } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 3cfe60464..07d3a63e6 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -100,24 +100,33 @@ Item { currentIndex: tabbar.currentIndex Item { - Receive { - id: receive + Loader { anchors.fill: parent + Receive { + id: receive + anchors.fill: parent + } } } Item { - History { - id: history + Loader { anchors.fill: parent + History { + id: history + anchors.fill: parent + } } } Item { enabled: !Daemon.currentWallet.isWatchOnly - Send { + Loader { anchors.fill: parent + Send { + anchors.fill: parent + } } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 6d3dc55ee..3107ee2ae 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -19,6 +19,7 @@ from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin from .qefx import QEFX from .qetxfinalizer import QETxFinalizer +from .qeinvoice import QEInvoice notification = None @@ -115,6 +116,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser') qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') + qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py new file mode 100644 index 000000000..52c8cde0e --- /dev/null +++ b/electrum/gui/qml/qeinvoice.py @@ -0,0 +1,235 @@ +import asyncio +from datetime import datetime + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.keystore import bip39_is_checksum_valid +from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, + maybe_extract_bolt11_invoice) +from electrum.invoices import Invoice, OnchainInvoice, LNInvoice +from electrum.transaction import PartialTxOutput + +from .qewallet import QEWallet + +class QEInvoice(QObject): + + _logger = get_logger(__name__) + + class Type: + Invalid = -1 + OnchainOnlyAddress = 0 + OnchainInvoice = 1 + LightningInvoice = 2 + LightningAndOnchainInvoice = 3 + + Q_ENUMS(Type) + + _wallet = None + _invoiceType = Type.Invalid + _recipient = '' + _effectiveInvoice = None + _message = '' + _amount = 0 + + validationError = pyqtSignal([str,str], arguments=['code', 'message']) + validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) + invoiceSaved = pyqtSignal() + + def __init__(self, config, parent=None): + super().__init__(parent) + self.config = config + self.clear() + + invoiceTypeChanged = pyqtSignal() + @pyqtProperty(int, notify=invoiceTypeChanged) + def invoiceType(self): + return self._invoiceType + + # not a qt setter, don't let outside set state + def setInvoiceType(self, invoiceType: Type): + #if self._invoiceType != invoiceType: + self._invoiceType = invoiceType + self.invoiceTypeChanged.emit() + + 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.walletChanged.emit() + + recipientChanged = pyqtSignal() + @pyqtProperty(str, notify=recipientChanged) + def recipient(self): + return self._recipient + + @recipient.setter + def recipient(self, recipient: str): + #if self._recipient != recipient: + self._recipient = recipient + if recipient: + self.validateRecipient(recipient) + self.recipientChanged.emit() + + messageChanged = pyqtSignal() + @pyqtProperty(str, notify=messageChanged) + def message(self): + return self._message + + amountChanged = pyqtSignal() + @pyqtProperty('quint64', notify=amountChanged) + def amount(self): + return self._amount + + + @pyqtSlot() + def clear(self): + self.recipient = '' + self.invoiceSetsAmount = False + self.setInvoiceType(QEInvoice.Type.Invalid) + self._bip21 = None + + def setValidAddressOnly(self): + self._logger.debug('setValidAddressOnly') + self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) + self._effectiveInvoice = None ###TODO + + def setValidOnchainInvoice(self, invoice: OnchainInvoice): + self._logger.debug('setValidOnchainInvoice') + self.setInvoiceType(QEInvoice.Type.OnchainInvoice) + self._amount = invoice.get_amount_sat() + self.amountChanged.emit() + self._message = invoice.message + self.messageChanged.emit() + + self._effectiveInvoice = invoice + + def setValidLightningInvoice(self, invoice: LNInvoice): + self._logger.debug('setValidLightningInvoice') + self.setInvoiceType(QEInvoice.Type.LightningInvoice) + self._effectiveInvoice = invoice + + self._amount = int(invoice.get_amount_sat()) # TODO: float/str msat precision + self.amountChanged.emit() + self._message = invoice.message + self.messageChanged.emit() + + def create_onchain_invoice(self, outputs, message, payment_request, uri): + return self._wallet.wallet.create_invoice( + outputs=outputs, + message=message, + pr=payment_request, + URI=uri + ) + + def validateRecipient(self, recipient): + if not recipient: + self.setInvoiceType(QEInvoice.Type.Invalid) + return + + maybe_lightning_invoice = recipient + + def _payment_request_resolved(request): + self._logger.debug('resolved payment request') + outputs = request.get_outputs() + invoice = self.create_onchain_invoice(outputs, None, request, None) + self.setValidOnchainInvoice(invoice) + + try: + self._bip21 = parse_URI(recipient, _payment_request_resolved) + if self._bip21: + if 'r' in self._bip21 or ('name' in self._bip21 and 'sig' in self._bip21): # TODO set flag in util? + # let callback handle state + return + if ':' not in recipient: + # address only + self.setValidAddressOnly() + return + else: + # fallback lightning invoice? + if 'lightning' in self._bip21: + maybe_lightning_invoice = self._bip21['lightning'] + except InvalidBitcoinURI as e: + self._bip21 = None + self._logger.debug(repr(e)) + + lninvoice = None + try: + maybe_lightning_invoice = maybe_extract_bolt11_invoice(maybe_lightning_invoice) + lninvoice = LNInvoice.from_bech32(maybe_lightning_invoice) + except InvoiceError as e: + pass + + if not lninvoice and not self._bip21: + self.validationError.emit('unknown',_('Unknown invoice')) + self.clear() + return + + if lninvoice: + if not self._wallet.wallet.has_lightning(): + if not self._bip21: + # TODO: lightning onchain fallback in ln invoice + #self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet')) + self.setValidLightningInvoice(lninvoice) + self.clear() + return + else: + self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') + self.setValidOnchainInvoice(self._bip21['address']) + elif not self._wallet.wallet.lnworker.channels: + self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) + self.setValidLightningInvoice(lninvoice) + else: + self._logger.debug('flow without LN but having bip21 uri') + if 'amount' not in self._bip21: #TODO can we have amount-less invoices? + self.validationError.emit('no_amount', 'no amount in uri') + return + outputs = [PartialTxOutput.from_address_and_value(self._bip21['address'], self._bip21['amount'])] + self._logger.debug(outputs) + message = self._bip21['message'] if 'message' in self._bip21 else '' + invoice = self.create_onchain_invoice(outputs, message, None, self._bip21) + self._logger.debug(invoice) + self.setValidOnchainInvoice(invoice) + + @pyqtSlot() + def save_invoice(self): + if not self._effectiveInvoice: + return + self._wallet.wallet.save_invoice(self._effectiveInvoice) + self.invoiceSaved.emit() + + @pyqtSlot(str, 'quint64', str) + def create_invoice(self, address: str, amount: int, message: str): + # create onchain invoice from user entered fields + # (any other type of invoice is created from parsing recipient) + self._logger.debug('saving invoice to %s' % address) + if not address: + self.invoiceCreateError.emit('fatal', _('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) + return + + if not bitcoin.is_address(address): + self.invoiceCreateError.emit('fatal', _('Invalid Bitcoin address')) + return + + if not self.amount: + self.invoiceCreateError.emit('fatal', _('Invalid amount')) + return + + + + # + if self.is_max: + amount = '!' + else: + try: + amount = self.app.get_amount(self.amount) + except: + self.app.show_error(_('Invalid amount') + ':\n' + self.amount) + return + diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 596949894..26c9a6bae 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -51,7 +51,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): invoices.append(item) self.clear() - self.beginInsertRows(QModelIndex(), 0, len(self.invoices) - 1) + self.beginInsertRows(QModelIndex(), 0, len(invoices) - 1) self.invoices = invoices self.endInsertRows() @@ -79,7 +79,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): i = 0 for item in self.invoices: if item['key'] == key: - invoice = self.get_invoice_for_key(key) #self.wallet.get_invoice(key) + invoice = self.get_invoice_for_key(key) item['status'] = status item['status_str'] = invoice.get_status_str(status) index = self.index(i,0) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index 3bd856e32..6d7204ffb 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -10,7 +10,7 @@ from PIL import Image, ImageQt from electrum.logging import get_logger from electrum.qrreader import get_qr_reader from electrum.i18n import _ - +from electrum.util import profiler class QEQRParser(QObject): def __init__(self, text=None, parent=None): @@ -123,6 +123,7 @@ class QEQRImageProvider(QQuickImageProvider): _logger = get_logger(__name__) + @profiler def requestImage(self, qstr, size): self._logger.debug('QR requested for %s' % qstr) qr = qrcode.QRCode(version=1, box_size=6, border=2) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 7413e4c20..bfcbc16d1 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -44,6 +44,9 @@ class QEWallet(QObject): requestStatusChanged = pyqtSignal([str,int], arguments=['key','status']) requestCreateSuccess = pyqtSignal() requestCreateError = pyqtSignal([str,str], arguments=['code','error']) + invoiceStatusChanged = pyqtSignal([str], arguments=['key']) + invoiceCreateSuccess = pyqtSignal() + invoiceCreateError = pyqtSignal([str,str], arguments=['code','error']) _network_signal = pyqtSignal(str, object) @@ -95,10 +98,15 @@ class QEWallet(QObject): if event == 'status': self.isUptodateChanged.emit() elif event == 'request_status': - wallet, addr, c = args + wallet, key, status = args if wallet == self.wallet: - self._logger.debug('request status %d for address %s' % (c, addr)) - self.requestStatusChanged.emit(addr, c) + self._logger.debug('request status %d for key %s' % (status, key)) + self.requestStatusChanged.emit(key, status) + elif event == 'invoice_status': + wallet, key = args + if wallet == self.wallet: + self._logger.debug('invoice status %d for key %s' % (c, key)) + self.invoiceStatusChanged.emit(key) elif event == 'new_transaction': wallet, tx = args if wallet == self.wallet: @@ -351,5 +359,5 @@ class QEWallet(QObject): @pyqtSlot('QString', result='QVariant') def get_invoice(self, key: str): - req = self.wallet.get_invoice(key) - return self._invoiceModel.invoice_to_model(req) + invoice = self.wallet.get_invoice(key) + return self._invoiceModel.invoice_to_model(invoice) From 503139148459be46af48d7ff28b654bbaab5ed95 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 25 Apr 2022 15:55:34 +0200 Subject: [PATCH 123/218] add a QEAmount type for storing/passing BTC amounts in the widest sense from a UI perspective. Stores sats, millisats (LN), whether MAX amount is requested etc some refactor QEInvoice type and Send page --- .../qml/components/ConfirmInvoiceDialog.qml | 19 ++++- electrum/gui/qml/components/Send.qml | 11 ++- electrum/gui/qml/qeapp.py | 5 +- electrum/gui/qml/qebitcoin.py | 6 +- electrum/gui/qml/qeconfig.py | 8 +- electrum/gui/qml/qefx.py | 33 ++++++-- electrum/gui/qml/qeinvoice.py | 80 ++++++++++++------- electrum/gui/qml/qeinvoicelistmodel.py | 8 +- electrum/gui/qml/qetransactionlistmodel.py | 7 ++ electrum/gui/qml/qetypes.py | 55 +++++++++++++ 10 files changed, 184 insertions(+), 48 deletions(-) create mode 100644 electrum/gui/qml/qetypes.py diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml index 7ebeeaeb0..f1c4bf3ef 100644 --- a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml +++ b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml @@ -11,6 +11,7 @@ Dialog { id: dialog property Invoice invoice + property string invoice_key width: parent.width height: parent.height @@ -84,9 +85,20 @@ Dialog { } } + Label { + text: qsTr('Expiration') + visible: true + } + + Label { + id: expiration + text: invoice.time + invoice.expiration + } + RowLayout { Layout.columnSpan: 2 - Layout.alignment: Qt.AlignHCenter + Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom + Layout.fillHeight: true spacing: constants.paddingMedium Button { @@ -115,4 +127,9 @@ Dialog { } + Component.onCompleted: { + if (invoice_key != '') { + invoice.initFromKey(invoice_key) + } + } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 5a464f579..14eb92ee9 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -213,7 +213,12 @@ Pane { model: DelegateModel { id: delegateModel model: Daemon.currentWallet.invoiceModel - delegate: InvoiceDelegate {} + delegate: InvoiceDelegate { + onClicked: { + var dialog = confirmInvoiceDialog.createObject(app, {'invoice' : invoice, 'invoice_key': model.key}) + dialog.open() + } + } } remove: Transition { @@ -288,9 +293,7 @@ Pane { // and maybe store invoice if expiry allows } } - onInvoiceTypeChanged: { - if (invoiceType == Invoice.Invalid) - return + onValidationSuccess: { // address only -> fill form fields // else -> show invoice confirmation dialog if (invoiceType == Invoice.OnchainOnlyAddress) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 3107ee2ae..aa7722e8b 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -5,7 +5,7 @@ import os from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl, QLocale, qInstallMessageHandler, QTimer from PyQt5.QtGui import QGuiApplication, QFontDatabase -from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine +from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplicationEngine from electrum.logging import Logger, get_logger from electrum import version @@ -20,6 +20,7 @@ from .qebitcoin import QEBitcoin from .qefx import QEFX from .qetxfinalizer import QETxFinalizer from .qeinvoice import QEInvoice +from .qetypes import QEAmount notification = None @@ -118,6 +119,8 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') + qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') + self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index d1f6b681c..e001a509e 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -10,6 +10,8 @@ from electrum.slip39 import decode_mnemonic, Slip39Error from electrum import mnemonic from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI +from .qetypes import QEAmount + class QEBitcoin(QObject): def __init__(self, config, parent=None): super().__init__(parent) @@ -121,11 +123,11 @@ class QEBitcoin(QObject): except InvalidBitcoinURI as e: return { 'error': str(e) } - @pyqtSlot(str, 'qint64', str, int, int, result=str) + @pyqtSlot(str, QEAmount, str, int, int, result=str) def create_uri(self, address, satoshis, message, timestamp, expiry): extra_params = {} if expiry: extra_params['time'] = str(timestamp) extra_params['exp'] = str(expiry) - return create_bip21_uri(address, satoshis, message, extra_query_params=extra_params) + return create_bip21_uri(address, satoshis.satsInt, message, extra_query_params=extra_params) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index f385c3ba4..411eac7c3 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -5,6 +5,8 @@ from decimal import Decimal from electrum.logging import get_logger from electrum.util import DECIMAL_POINT_DEFAULT +from .qetypes import QEAmount + class QEConfig(QObject): def __init__(self, config, parent=None): super().__init__(parent) @@ -70,7 +72,11 @@ class QEConfig(QObject): @pyqtSlot('qint64', result=str) @pyqtSlot('qint64', bool, result=str) + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) def formatSats(self, satoshis, with_unit=False): + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt if with_unit: return self.config.format_amount_and_units(satoshis) else: @@ -85,11 +91,11 @@ class QEConfig(QObject): @pyqtSlot(str, result='qint64') def unitsToSats(self, unitAmount): - # returns amt in satoshis try: x = Decimal(unitAmount) except: return 0 + # scale it to max allowed precision, make it an int max_prec_amount = int(pow(10, self.max_precision()) * x) # if the max precision is simply what unit conversion allows, just return diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index abe2f87f6..66a634f87 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -9,6 +9,8 @@ from electrum.simple_config import SimpleConfig from electrum.util import register_callback from electrum.bitcoin import COIN +from .qetypes import QEAmount + class QEFX(QObject): def __init__(self, fxthread: FxThread, config: SimpleConfig, parent=None): super().__init__(parent) @@ -88,25 +90,40 @@ class QEFX(QObject): @pyqtSlot(str, result=str) @pyqtSlot(str, bool, result=str) + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) def fiatValue(self, satoshis, plain=True): rate = self.fx.exchange_rate() - try: - sd = Decimal(satoshis) - if sd == 0: + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt + else: + try: + sd = Decimal(satoshis) + if sd == 0: + return '' + except: return '' - except: - return '' if plain: return self.fx.ccy_amount_str(self.fx.fiat_value(satoshis, rate), False) else: return self.fx.value_str(satoshis, rate) @pyqtSlot(str, str, result=str) + @pyqtSlot(str, str, bool, result=str) + @pyqtSlot(QEAmount, str, result=str) + @pyqtSlot(QEAmount, str, bool, result=str) def fiatValueHistoric(self, satoshis, timestamp, plain=True): - try: - sd = Decimal(satoshis) - if sd == 0: + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt + else: + try: + sd = Decimal(satoshis) + if sd == 0: + return '' + except: return '' + + try: td = Decimal(timestamp) if td == 0: return '' diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 52c8cde0e..1dda2e42f 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -12,6 +12,7 @@ from electrum.invoices import Invoice, OnchainInvoice, LNInvoice from electrum.transaction import PartialTxOutput from .qewallet import QEWallet +from .qetypes import QEAmount class QEInvoice(QObject): @@ -30,28 +31,26 @@ class QEInvoice(QObject): _invoiceType = Type.Invalid _recipient = '' _effectiveInvoice = None - _message = '' - _amount = 0 - validationError = pyqtSignal([str,str], arguments=['code', 'message']) - validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) + invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal() + validationSuccess = pyqtSignal() + validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) + validationError = pyqtSignal([str,str], arguments=['code', 'message']) + def __init__(self, config, parent=None): super().__init__(parent) self.config = config self.clear() - invoiceTypeChanged = pyqtSignal() - @pyqtProperty(int, notify=invoiceTypeChanged) + @pyqtProperty(int, notify=invoiceChanged) def invoiceType(self): return self._invoiceType # not a qt setter, don't let outside set state def setInvoiceType(self, invoiceType: Type): - #if self._invoiceType != invoiceType: - self._invoiceType = invoiceType - self.invoiceTypeChanged.emit() + self._invoiceType = invoiceType walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) @@ -77,16 +76,29 @@ class QEInvoice(QObject): self.validateRecipient(recipient) self.recipientChanged.emit() - messageChanged = pyqtSignal() - @pyqtProperty(str, notify=messageChanged) + @pyqtProperty(str, notify=invoiceChanged) def message(self): - return self._message + return self._effectiveInvoice.message if self._effectiveInvoice else '' - amountChanged = pyqtSignal() - @pyqtProperty('quint64', notify=amountChanged) + @pyqtProperty(QEAmount, notify=invoiceChanged) def amount(self): + # store ref to QEAmount on instance, otherwise we get destroyed when going out of scope + self._amount = QEAmount() # + if not self._effectiveInvoice: + return self._amount + sats = self._effectiveInvoice.get_amount_sat() + if not sats: + return self._amount + self._amount = QEAmount(amount_sat=sats) return self._amount + @pyqtProperty('quint64', notify=invoiceChanged) + def expiration(self): + return self._effectiveInvoice.exp if self._effectiveInvoice else 0 + + @pyqtProperty('quint64', notify=invoiceChanged) + def time(self): + return self._effectiveInvoice.time if self._effectiveInvoice else 0 @pyqtSlot() def clear(self): @@ -94,31 +106,38 @@ class QEInvoice(QObject): self.invoiceSetsAmount = False self.setInvoiceType(QEInvoice.Type.Invalid) self._bip21 = None + self.invoiceChanged.emit() + + # don't parse the recipient string, but init qeinvoice from an invoice key + # this should not emit validation signals + @pyqtSlot(str) + def initFromKey(self, key): + invoice = self._wallet.wallet.get_invoice(key) + self._logger.debug(repr(invoice)) + if invoice: + self.set_effective_invoice(invoice) + + def set_effective_invoice(self, invoice: Invoice): + self._effectiveInvoice = invoice + if invoice.is_lightning(): + self.setInvoiceType(QEInvoice.Type.LightningInvoice) + else: + self.setInvoiceType(QEInvoice.Type.OnchainInvoice) + self.invoiceChanged.emit() def setValidAddressOnly(self): self._logger.debug('setValidAddressOnly') self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) self._effectiveInvoice = None ###TODO + self.invoiceChanged.emit() def setValidOnchainInvoice(self, invoice: OnchainInvoice): self._logger.debug('setValidOnchainInvoice') - self.setInvoiceType(QEInvoice.Type.OnchainInvoice) - self._amount = invoice.get_amount_sat() - self.amountChanged.emit() - self._message = invoice.message - self.messageChanged.emit() - - self._effectiveInvoice = invoice + self.set_effective_invoice(invoice) def setValidLightningInvoice(self, invoice: LNInvoice): self._logger.debug('setValidLightningInvoice') - self.setInvoiceType(QEInvoice.Type.LightningInvoice) - self._effectiveInvoice = invoice - - self._amount = int(invoice.get_amount_sat()) # TODO: float/str msat precision - self.amountChanged.emit() - self._message = invoice.message - self.messageChanged.emit() + self.set_effective_invoice(invoice) def create_onchain_invoice(self, outputs, message, payment_request, uri): return self._wallet.wallet.create_invoice( @@ -150,6 +169,7 @@ class QEInvoice(QObject): if ':' not in recipient: # address only self.setValidAddressOnly() + self.validationSuccess.emit() return else: # fallback lightning invoice? @@ -194,13 +214,15 @@ class QEInvoice(QObject): self._logger.debug(outputs) message = self._bip21['message'] if 'message' in self._bip21 else '' invoice = self.create_onchain_invoice(outputs, message, None, self._bip21) - self._logger.debug(invoice) + self._logger.debug(repr(invoice)) self.setValidOnchainInvoice(invoice) + self.validationSuccess.emit() @pyqtSlot() def save_invoice(self): if not self._effectiveInvoice: return + # TODO detect duplicate? self._wallet.wallet.save_invoice(self._effectiveInvoice) self.invoiceSaved.emit() diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 26c9a6bae..00b5d339c 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -7,6 +7,8 @@ from electrum.logging import get_logger from electrum.util import Satoshis, format_time from electrum.invoices import Invoice +from .qetypes import QEAmount + class QEAbstractInvoiceListModel(QAbstractListModel): _logger = get_logger(__name__) @@ -35,6 +37,8 @@ class QEAbstractInvoiceListModel(QAbstractListModel): return value if isinstance(value, Satoshis): return value.value + if isinstance(value, QEAmount): + return value return str(value) def clear(self): @@ -111,7 +115,7 @@ class QEInvoiceListModel(QEAbstractInvoiceListModel): item = self.wallet.export_invoice(invoice) item['type'] = invoice.type # 0=onchain, 2=LN item['date'] = format_time(item['timestamp']) - item['amount'] = invoice.get_amount_sat() + item['amount'] = QEAmount(amount_sat=invoice.get_amount_sat()) if invoice.type == 0: item['key'] = invoice.id elif invoice.type == 2: @@ -136,7 +140,7 @@ class QERequestListModel(QEAbstractInvoiceListModel): item['key'] = self.wallet.get_key_for_receive_request(req) item['type'] = req.type # 0=onchain, 2=LN item['date'] = format_time(item['timestamp']) - item['amount'] = req.get_amount_sat() + item['amount'] = QEAmount(amount_sat=req.get_amount_sat()) return item diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 453e1a85b..f4243a16a 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -6,6 +6,8 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.logging import get_logger from electrum.util import Satoshis, TxMinedInfo +from .qetypes import QEAmount + class QETransactionListModel(QAbstractListModel): def __init__(self, wallet, parent=None): super().__init__(parent) @@ -36,6 +38,8 @@ class QETransactionListModel(QAbstractListModel): return value if isinstance(value, Satoshis): return value.value + if isinstance(value, QEAmount): + return value return str(value) def clear(self): @@ -48,6 +52,9 @@ class QETransactionListModel(QAbstractListModel): for output in item['outputs']: output['value'] = output['value'].value + item['bc_value'] = QEAmount(amount_sat=item['bc_value'].value) + item['bc_balance'] = QEAmount(amount_sat=item['bc_balance'].value) + # newly arriving txs have no (block) timestamp # TODO? if not item['timestamp']: diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py new file mode 100644 index 000000000..c2f207189 --- /dev/null +++ b/electrum/gui/qml/qetypes.py @@ -0,0 +1,55 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.util import profiler + +# container for satoshi amounts that can be passed around more +# easily between python, QML-property and QML-javascript contexts +# QML 'int' is 32 bit signed, so overflows on satoshi amounts +# QML 'quint64' and 'qint64' can be used, but this breaks +# down when passing through property bindings +# should also capture millisats amounts and MAX/'!' indicators +# and (unformatted) string representations + +class QEAmount(QObject): + _logger = get_logger(__name__) + + def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, parent=None): + super().__init__(parent) + self._amount_sat = amount_sat + self._amount_msat = amount_msat + self._is_max = is_max + + valueChanged = pyqtSignal() + + @pyqtProperty('qint64', notify=valueChanged) + def satsInt(self): + return self._amount_sat + + @pyqtProperty('qint64', notify=valueChanged) + def msatsInt(self): + return self._amount_msat + + @pyqtProperty(str, notify=valueChanged) + def satsStr(self): + return str(self._amount_sat) + + @pyqtProperty(str, notify=valueChanged) + def msatsStr(self): + return str(self._amount_msat) + + @pyqtProperty(bool, notify=valueChanged) + def isMax(self): + return self._is_max + + def __eq__(self, other): + self._logger.debug('__eq__') + if isinstance(other, QEAmount): + return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max + elif isinstance(other, int): + return self._amount_sat == other + elif isinstance(other, str): + return self.satsStr == other + + return False From a163268d79e4ccfd3c30d21f87a82c6a52c3ee8f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 26 Apr 2022 10:38:15 +0200 Subject: [PATCH 124/218] more QEAmount refactoring --- electrum/gui/qml/components/Receive.qml | 6 +++--- electrum/gui/qml/components/Send.qml | 2 +- electrum/gui/qml/qeconfig.py | 10 ++++++---- electrum/gui/qml/qetxfinalizer.py | 12 +++++++----- electrum/gui/qml/qewallet.py | 13 +++++++------ 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index ddf2c00b8..4703b3373 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -43,12 +43,12 @@ Pane { placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhPreferNumbers - property string textAsSats + property Amount textAsSats onTextChanged: { textAsSats = Config.unitsToSats(amount.text) if (amountFiat.activeFocus) return - amountFiat.text = Daemon.fx.fiatValue(amount.textAsSats) + amountFiat.text = text == '' ? '' : Daemon.fx.fiatValue(amount.textAsSats) } Connections { @@ -109,7 +109,7 @@ Pane { 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}) - expiresmodel.append({'text': qsTr('1 month'), 'value': 31*7*24*60*60}) + expiresmodel.append({'text': qsTr('1 month'), 'value': 31*24*60*60}) expiresmodel.append({'text': qsTr('Never'), 'value': 0}) expires.currentIndex = 0 } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 14eb92ee9..aa2b07e59 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -77,7 +77,7 @@ Pane { placeholderText: qsTr('Amount') Layout.preferredWidth: parent.width /2 inputMethodHints: Qt.ImhPreferNumbers - property string textAsSats + property Amount textAsSats onTextChanged: { textAsSats = Config.unitsToSats(amount.text) if (amountFiat.activeFocus) diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 411eac7c3..64ab26873 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -89,23 +89,25 @@ class QEConfig(QObject): def max_precision(self): return self.decimal_point() + 0 #self.extra_precision - @pyqtSlot(str, result='qint64') + @pyqtSlot(str, result=QEAmount) def unitsToSats(self, unitAmount): + self._amount = QEAmount() try: x = Decimal(unitAmount) except: - return 0 + return self._amount # scale it to max allowed precision, make it an int max_prec_amount = int(pow(10, self.max_precision()) * x) # if the max precision is simply what unit conversion allows, just return if self.max_precision() == self.decimal_point(): - return max_prec_amount + self._amount = QEAmount(amount_sat=max_prec_amount) + return self._amount self._logger.debug('fallthrough') # otherwise, scale it back to the expected unit #amount = Decimal(max_prec_amount) / Decimal(pow(10, self.max_precision()-self.decimal_point())) #return int(amount) #Decimal(amount) if not self.is_int else int(amount) - return 0 + return self._amount @pyqtSlot('quint64', result=float) def satsToUnits(self, satoshis): diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index c1bb3bad2..3aa3faee1 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -8,6 +8,7 @@ from electrum.transaction import PartialTxOutput from electrum.util import NotEnoughFunds, profiler from .qewallet import QEWallet +from .qetypes import QEAmount class QETxFinalizer(QObject): def __init__(self, parent=None): @@ -16,7 +17,7 @@ class QETxFinalizer(QObject): _logger = get_logger(__name__) _address = '' - _amount = '' + _amount = QEAmount() _fee = '' _feeRate = '' _wallet = None @@ -58,14 +59,14 @@ class QETxFinalizer(QObject): self.addressChanged.emit() amountChanged = pyqtSignal() - @pyqtProperty(str, notify=amountChanged) + @pyqtProperty(QEAmount, 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._logger.info('amount = "%s"' % repr(amount)) self._amount = amount self.amountChanged.emit() @@ -181,7 +182,7 @@ class QETxFinalizer(QObject): @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))] + outputs = [PartialTxOutput.from_address_and_value(self.address, self._amount.satsInt)] 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 @@ -204,7 +205,8 @@ class QETxFinalizer(QObject): self.validChanged.emit() return - amount = int(self.amount) if self.amount != '!' else tx.output_value() + amount = self._amount.satsInt if not self._amount.isMax else tx.output_value() + tx_size = tx.estimated_size() fee = tx.get_fee() feerate = Decimal(fee) / tx_size # sat/byte diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index bfcbc16d1..64b072694 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -16,6 +16,7 @@ from electrum.invoices import (Invoice, InvoiceError, PR_TYPE_ONCHAIN, PR_TYPE from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel +from .qetypes import QEAmount class QEWallet(QObject): __instances = [] @@ -318,10 +319,10 @@ class QEWallet(QObject): self._requestModel.add_invoice(req) return addr - @pyqtSlot('quint64', 'QString', int) - @pyqtSlot('quint64', 'QString', int, bool) - @pyqtSlot('quint64', 'QString', int, bool, bool) - def create_request(self, amount: int, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False): + @pyqtSlot(QEAmount, 'QString', int) + @pyqtSlot(QEAmount, 'QString', int, bool) + @pyqtSlot(QEAmount, 'QString', int, bool, bool) + def create_request(self, amount: QEAmount, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False): expiry = expiration #TODO: self.config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING) try: if is_lightning: @@ -329,9 +330,9 @@ class QEWallet(QObject): self.requestCreateError.emit('fatal',_("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) + key = self.wallet.lnworker.add_request(amount.satsInt, message, expiry) else: - key = self.create_bitcoin_request(amount, message, expiry, ignore_gap) + key = self.create_bitcoin_request(amount.satsInt, message, expiry, ignore_gap) if not key: return self._addressModel.init_model() From 0dce872d3730fabaa8fce49d9ff3f7ffd387e680 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 26 Apr 2022 11:12:02 +0200 Subject: [PATCH 125/218] add invoice status --- .../qml/components/ConfirmInvoiceDialog.qml | 7 ++-- electrum/gui/qml/components/Send.qml | 2 -- electrum/gui/qml/qeinvoice.py | 34 +++++++++++++++++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml index f1c4bf3ef..7f3b447c5 100644 --- a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml +++ b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml @@ -86,13 +86,11 @@ Dialog { } Label { - text: qsTr('Expiration') - visible: true + text: qsTr('Status') } Label { - id: expiration - text: invoice.time + invoice.expiration + text: invoice.status_str } RowLayout { @@ -108,7 +106,6 @@ Dialog { Button { text: qsTr('Save') -// enabled: invoice.invoiceType != Invoice.Invalid enabled: invoice.invoiceType == Invoice.OnchainInvoice onClicked: { invoice.save_invoice() diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index aa2b07e59..cdda2be21 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -279,8 +279,6 @@ Pane { // no popups when editing return } - console.log(code + ' ' + message) - var dialog = app.messageDialog.createObject(app, {'text': message }) dialog.open() rootItem.clear() diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 1dda2e42f..8dd3c6dc5 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -9,6 +9,8 @@ from electrum.keystore import bip39_is_checksum_valid from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, maybe_extract_bolt11_invoice) from electrum.invoices import Invoice, OnchainInvoice, LNInvoice +from electrum.invoices import (PR_UNPAID,PR_EXPIRED,PR_UNKNOWN,PR_PAID,PR_INFLIGHT, + PR_FAILED,PR_ROUTING,PR_UNCONFIRMED) from electrum.transaction import PartialTxOutput from .qewallet import QEWallet @@ -25,7 +27,18 @@ class QEInvoice(QObject): LightningInvoice = 2 LightningAndOnchainInvoice = 3 + class Status: + Unpaid = PR_UNPAID + Expired = PR_EXPIRED + Unknown = PR_UNKNOWN + Paid = PR_PAID + Inflight = PR_INFLIGHT + Failed = PR_FAILED + Routing = PR_ROUTING + Unconfirmed = PR_UNCONFIRMED + Q_ENUMS(Type) + Q_ENUMS(Status) _wallet = None _invoiceType = Type.Invalid @@ -83,7 +96,7 @@ class QEInvoice(QObject): @pyqtProperty(QEAmount, notify=invoiceChanged) def amount(self): # store ref to QEAmount on instance, otherwise we get destroyed when going out of scope - self._amount = QEAmount() # + self._amount = QEAmount() if not self._effectiveInvoice: return self._amount sats = self._effectiveInvoice.get_amount_sat() @@ -100,6 +113,20 @@ class QEInvoice(QObject): def time(self): return self._effectiveInvoice.time if self._effectiveInvoice else 0 + statusChanged = pyqtSignal() + @pyqtProperty(int, notify=statusChanged) + def status(self): + if not self._effectiveInvoice: + return '' + status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) + + @pyqtProperty(str, notify=statusChanged) + def status_str(self): + if not self._effectiveInvoice: + return '' + status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) + return self._effectiveInvoice.get_status_str(status) + @pyqtSlot() def clear(self): self.recipient = '' @@ -124,6 +151,7 @@ class QEInvoice(QObject): else: self.setInvoiceType(QEInvoice.Type.OnchainInvoice) self.invoiceChanged.emit() + self.statusChanged.emit() def setValidAddressOnly(self): self._logger.debug('setValidAddressOnly') @@ -226,8 +254,8 @@ class QEInvoice(QObject): self._wallet.wallet.save_invoice(self._effectiveInvoice) self.invoiceSaved.emit() - @pyqtSlot(str, 'quint64', str) - def create_invoice(self, address: str, amount: int, message: str): + @pyqtSlot(str, QEAmount, str) + def create_invoice(self, address: str, amount: QEAmount, message: str): # create onchain invoice from user entered fields # (any other type of invoice is created from parsing recipient) self._logger.debug('saving invoice to %s' % address) From bf072b037ceb18e4fd01163e7e4e79fe93be7d29 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 26 Apr 2022 13:18:34 +0200 Subject: [PATCH 126/218] hook up invoice confirm to payment flow (onchain only) fix some leftover QEAmount issues --- .../gui/qml/components/ConfirmInvoiceDialog.qml | 16 +++++++++++++--- .../gui/qml/components/ConfirmPaymentDialog.qml | 5 +++-- electrum/gui/qml/components/Send.qml | 16 +++++++++++++--- electrum/gui/qml/qeinvoice.py | 9 +++++++-- electrum/gui/qml/qetxfinalizer.py | 9 +++++---- electrum/gui/qml/qetypes.py | 7 ++++++- 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml index 7f3b447c5..19cc51bd2 100644 --- a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml +++ b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml @@ -13,6 +13,8 @@ Dialog { property Invoice invoice property string invoice_key + signal doPay + width: parent.width height: parent.height @@ -57,6 +59,9 @@ Dialog { Label { text: invoice.message Layout.fillWidth: true + wrapMode: Text.Wrap + maximumLineCount: 4 + elide: Text.ElideRight } Label { @@ -93,10 +98,11 @@ Dialog { text: invoice.status_str } + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + RowLayout { Layout.columnSpan: 2 - Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom - Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter spacing: constants.paddingMedium Button { @@ -117,7 +123,11 @@ Dialog { text: qsTr('Pay now') enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds onClicked: { - console.log('pay now') + invoice.save_invoice() + dialog.close() + if (invoice.invoiceType == Invoice.OnchainInvoice) { + doPay() // only signal here + } } } } diff --git a/electrum/gui/qml/components/ConfirmPaymentDialog.qml b/electrum/gui/qml/components/ConfirmPaymentDialog.qml index 241c768f1..59f327113 100644 --- a/electrum/gui/qml/components/ConfirmPaymentDialog.qml +++ b/electrum/gui/qml/components/ConfirmPaymentDialog.qml @@ -164,6 +164,8 @@ Dialog { color: Material.accentColor } + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + RowLayout { Layout.columnSpan: 2 Layout.alignment: Qt.AlignHCenter @@ -184,12 +186,11 @@ Dialog { } } } - Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } } TxFinalizer { id: finalizer wallet: Daemon.currentWallet - onAmountChanged: console.log(amount) + onAmountChanged: console.log(amount.satsInt) } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index cdda2be21..c3ab6cfdc 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -157,10 +157,9 @@ Pane { var f_amount = parseFloat(amount.text) if (isNaN(f_amount)) return - var sats = Config.unitsToSats(amount.text).toString() var dialog = confirmPaymentDialog.createObject(app, { 'address': recipient.text, - 'satoshis': sats, + 'satoshis': Config.unitsToSats(amount.text), 'message': message.text }) dialog.open() @@ -244,7 +243,18 @@ Pane { Component { id: confirmInvoiceDialog - ConfirmInvoiceDialog {} + ConfirmInvoiceDialog { + onDoPay: { + if (invoice.invoiceType == Invoice.OnchainInvoice) { + var dialog = confirmPaymentDialog.createObject(rootItem, { + 'address': invoice.address, + 'satoshis': invoice.amount, + 'message': invoice.message + }) + dialog.open() + } + } + } } Connections { diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 8dd3c6dc5..c8133e266 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -117,8 +117,8 @@ class QEInvoice(QObject): @pyqtProperty(int, notify=statusChanged) def status(self): if not self._effectiveInvoice: - return '' - status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) + return PR_UNKNOWN + return self._wallet.wallet.get_invoice_status(self._effectiveInvoice) @pyqtProperty(str, notify=statusChanged) def status_str(self): @@ -127,6 +127,11 @@ class QEInvoice(QObject): status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) return self._effectiveInvoice.get_status_str(status) + # single address only, TODO: n outputs + @pyqtProperty(str, notify=invoiceChanged) + def address(self): + return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' + @pyqtSlot() def clear(self): self.recipient = '' diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 3aa3faee1..a71199fe9 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -18,7 +18,7 @@ class QETxFinalizer(QObject): _address = '' _amount = QEAmount() - _fee = '' + _fee = QEAmount() _feeRate = '' _wallet = None _valid = False @@ -66,12 +66,12 @@ class QETxFinalizer(QObject): @amount.setter def amount(self, amount): if self._amount != amount: - self._logger.info('amount = "%s"' % repr(amount)) + self._logger.debug(str(amount)) self._amount = amount self.amountChanged.emit() feeChanged = pyqtSignal() - @pyqtProperty(str, notify=feeChanged) + @pyqtProperty(QEAmount, notify=feeChanged) def fee(self): return self._fee @@ -211,9 +211,10 @@ class QETxFinalizer(QObject): fee = tx.get_fee() feerate = Decimal(fee) / tx_size # sat/byte - self.fee = str(fee) + self.fee = QEAmount(amount_sat=fee) self.feeRate = f'{feerate:.1f}' + #TODO #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) diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py index c2f207189..cf8c8faba 100644 --- a/electrum/gui/qml/qetypes.py +++ b/electrum/gui/qml/qetypes.py @@ -44,7 +44,6 @@ class QEAmount(QObject): return self._is_max def __eq__(self, other): - self._logger.debug('__eq__') if isinstance(other, QEAmount): return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max elif isinstance(other, int): @@ -53,3 +52,9 @@ class QEAmount(QObject): return self.satsStr == other return False + + def __str__(self): + s = _('Amount') + if self._is_max: + return '%s(MAX)' % s + return '%s(sats=%d, msats=%d)' % (s, self._amount_sat, self._amount_msat) From 300e5e21686c41fbe4497b1bed87169516af0f9c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 2 May 2022 17:43:33 +0200 Subject: [PATCH 127/218] add fiat to balance summary --- .../gui/qml/components/BalanceSummary.qml | 73 ++++++++++++++++--- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index ec8e8c320..edf0c00ec 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -10,10 +10,16 @@ Frame { property string formattedBalance property string formattedUnconfirmed + property string formattedBalanceFiat + property string formattedUnconfirmedFiat function setBalances() { - root.formattedBalance = Config.formatSats(Daemon.currentWallet.confirmedBalance, true) - root.formattedUnconfirmed = Config.formatSats(Daemon.currentWallet.unconfirmedBalance, true) + root.formattedBalance = Config.formatSats(Daemon.currentWallet.confirmedBalance) + root.formattedUnconfirmed = Config.formatSats(Daemon.currentWallet.unconfirmedBalance) + if (Daemon.fx.enabled) { + root.formattedBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.confirmedBalance.toString(), false) + root.formattedUnconfirmedFiat = Daemon.fx.fiatValue(Daemon.currentWallet.unconfirmedBalance.toString(), false) + } } GridLayout { @@ -24,24 +30,67 @@ Frame { font.pixelSize: constants.fontSizeLarge text: qsTr('Balance: ') } - Label { - font.pixelSize: constants.fontSizeLarge - color: Material.accentColor - text: formattedBalance + RowLayout { + Label { + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + text: formattedBalance + } + Label { + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeMedium + text: Daemon.fx.enabled + ? '(' + root.formattedBalanceFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } } Label { text: qsTr('Confirmed: ') + font.pixelSize: constants.fontSizeSmall } - Label { - color: Material.accentColor - text: formattedBalance + RowLayout { + Label { + font.pixelSize: constants.fontSizeSmall + font.family: FixedFont + text: formattedBalance + } + Label { + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeSmall + text: Daemon.fx.enabled + ? '(' + root.formattedBalanceFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } } Label { + font.pixelSize: constants.fontSizeSmall text: qsTr('Unconfirmed: ') } - Label { - color: Material.accentColor - text: formattedUnconfirmed + RowLayout { + Label { + font.pixelSize: constants.fontSizeSmall + font.family: FixedFont + text: formattedUnconfirmed + } + Label { + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeSmall + text: Daemon.fx.enabled + ? '(' + root.formattedUnconfirmedFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } } } From 2b691c92163046241275df09f7f43eaebde43a4f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 2 May 2022 18:08:56 +0200 Subject: [PATCH 128/218] small fixes --- electrum/gui/qml/components/WalletMainView.qml | 3 +++ electrum/gui/qml/components/Wallets.qml | 2 +- electrum/gui/qml/qefx.py | 4 ++-- electrum/gui/qml/qewallet.py | 6 +++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 07d3a63e6..f41cb7055 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -81,13 +81,16 @@ Item { currentIndex: swipeview.currentIndex TabButton { text: qsTr('Receive') + font.pixelSize: constants.fontSizeLarge } TabButton { text: qsTr('History') + font.pixelSize: constants.fontSizeLarge } TabButton { enabled: !Daemon.currentWallet.isWatchOnly text: qsTr('Send') + font.pixelSize: constants.fontSizeLarge } Component.onCompleted: tabbar.setCurrentIndex(1) } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index c3a7696a3..b131953fe 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -10,7 +10,7 @@ import "controls" Pane { id: rootItem - property string title: 'Wallets' + property string title: qsTr('Wallets') ColumnLayout { id: layout diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index 66a634f87..2102a2cdd 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -99,8 +99,8 @@ class QEFX(QObject): else: try: sd = Decimal(satoshis) - if sd == 0: - return '' + #if sd == 0: + #return '' except: return '' if plain: diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 64b072694..2b499d97d 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -225,15 +225,15 @@ class QEWallet(QObject): balanceChanged = pyqtSignal() - @pyqtProperty(int, notify=balanceChanged) + @pyqtProperty('quint64', notify=balanceChanged) def frozenBalance(self): return self.wallet.get_frozen_balance() - @pyqtProperty(int, notify=balanceChanged) + @pyqtProperty('quint64', notify=balanceChanged) def unconfirmedBalance(self): return self.wallet.get_balance()[1] - @pyqtProperty(int, notify=balanceChanged) + @pyqtProperty('quint64', notify=balanceChanged) def confirmedBalance(self): c, u, x = self.wallet.get_balance() self._logger.info('balance: ' + str(c) + ' ' + str(u) + ' ' + str(x) + ' ') From ef91969fba9ae0e14799e53bbab5fbe4b4fe2e8b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 3 May 2022 13:55:04 +0200 Subject: [PATCH 129/218] support update of address in addresslistmodel --- electrum/gui/qml/qeaddresslistmodel.py | 40 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index e32caf180..35bd3819b 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -44,6 +44,16 @@ class QEAddressListModel(QAbstractListModel): self.change_addresses = [] self.endResetModel() + def addr_to_model(self, address): + item = {} + item['address'] = address + item['numtx'] = self.wallet.get_address_history_len(address) + item['label'] = self.wallet.get_label(address) + c, u, x = self.wallet.get_addr_balance(address) + item['balance'] = c + u + x + item['held'] = self.wallet.is_frozen_address(address) + return item + # initial model data @pyqtSlot() def init_model(self): @@ -52,16 +62,10 @@ class QEAddressListModel(QAbstractListModel): n_addresses = len(r_addresses) + len(c_addresses) def insert_row(atype, alist, address, iaddr): - item = {} + item = self.addr_to_model(address) item['type'] = atype - item['address'] = address - item['numtx'] = self.wallet.get_address_history_len(address) - item['label'] = self.wallet.get_label(address) - c, u, x = self.wallet.get_addr_balance(address) - item['balance'] = c + u + x - item['held'] = self.wallet.is_frozen_address(address) - alist.append(item) item['iaddr'] = iaddr + alist.append(item) self.clear() self.beginInsertRows(QModelIndex(), 0, n_addresses - 1) @@ -75,3 +79,23 @@ class QEAddressListModel(QAbstractListModel): i = i + 1 self.endInsertRows() + @pyqtSlot(str) + def update_address(self, address): + i = 0 + for a in self.receive_addresses: + if a['address'] == address: + self.do_update(i,a) + return + i = i + 1 + for a in self.change_addresses: + if a['address'] == address: + self.do_update(i,a) + return + i = i + 1 + + def do_update(self, modelindex, modelitem): + mi = self.createIndex(modelindex, 0) + self._logger.debug(repr(modelitem)) + modelitem |= self.addr_to_model(modelitem['address']) + self._logger.debug(repr(modelitem)) + self.dataChanged.emit(mi, mi, self._ROLE_KEYS) From bb2b1738b7fb0ec6581d2671a10bc76f5157bd67 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 4 May 2022 15:01:50 +0200 Subject: [PATCH 130/218] add initial address detail page --- electrum/gui/icons/pen.png | Bin 0 -> 1641 bytes .../gui/qml/components/AddressDetails.qml | 243 ++++++++++++++++++ electrum/gui/qml/components/Addresses.qml | 78 +----- .../controls/GenericShareDialog.qml | 107 ++++++++ .../components/controls/TextHighlightPane.qml | 10 + electrum/gui/qml/qeaddressdetails.py | 113 ++++++++ electrum/gui/qml/qeapp.py | 2 + 7 files changed, 483 insertions(+), 70 deletions(-) create mode 100644 electrum/gui/icons/pen.png create mode 100644 electrum/gui/qml/components/AddressDetails.qml create mode 100644 electrum/gui/qml/components/controls/GenericShareDialog.qml create mode 100644 electrum/gui/qml/components/controls/TextHighlightPane.qml create mode 100644 electrum/gui/qml/qeaddressdetails.py diff --git a/electrum/gui/icons/pen.png b/electrum/gui/icons/pen.png new file mode 100644 index 0000000000000000000000000000000000000000..74b9468e5b958186bb8dce3460848d674f3da218 GIT binary patch literal 1641 zcmV-v2A27WP)WFU8GbZ8()Nlj2>E@cM*00qrSL_t(|+TELLh*V`5 z$A8bNZON9VXem)^NZzPOA}9nip+eC$DKDv+7KN9tmJwaC$dVFM%rMi%h}Ol-B+$sP z4{Fet*eFU&40S81EpN5T=>Gd~o&%@T%+9*Z*n8&rvdlX>XU_9~|NrN9-Z4@psRrl) zoCoB9qh@xT3N=ePKLZ#G^ar{EXMkqlQ!`sv(Evz#3aA5=0w#XF#LPBV8UR)9{Inv@ zZ;YLHiBt{%?$Crh@5RnHnAuWyeh{!h(qc*7D}@0U1ASAHh@IbTX3O3A-oTSUPhcdl zUDA9>{WBc{Bn<@C=h^wyW>)Xc4*}Nrx>8`ZI#4UJ%77fumU6y6;rs~T-GsxIiWBDp zQzbP>x;l#h90giZJHOhUzY$pC@2Q8R-up@Evr2@GMSSD#=&cpsMbcxzGQe3-4Kx6kl;L}0W^FOESA%1K zv)~l)UI}ACzUpzjq=i8O;2>-Q_Lo=+X%HqznjM@35>mJb*me%?Wrdl25EKKP1;>C@ zWs*WNCd~KdD=Y>in9vAZR^rr{wl?5S&qBcgPy=l7{=Zx-FmKN%ovTrNRfmx>I{_>z zG8-sGQ0xIb7fcLDf$#;ewTu#oK^P(F?w}-)lEV^UL>c2jeBC)_))XQD7kZJ@x6DCt zKQP10+JcZkLJG%$xxkS!Ixk=V@K7)@AO*tDesis5tPcPtNg5s;01m=F;CbM5ndLcG zQdMvOI0(CdhaH4+F<~e$KA0d_ATW*sUIe<8Q4Y6yJrWKB5=_|POlU1bu@qlxesB^< zfzagH;dGf==)pdy2@?PZVGl61#B&4#egYN%y@LiIks|K}_LKkw0SAEDKsE5bq?>~V zz(L5F*({)uf1f)zTiqW3<^a8b-^@sbfOe~z&pe>M29ot(tD?w zEew_cDK>l!+~ty>2pi({W?&w0frHmGPscbh6AVZ&p&A%Nbkqh4AIjS6DYZNBHqfWQ z|Nae3H?zZ;03ZQGA7B|!%YTpE9QFVzU@g&^#rCYp%;p3wfjpA<4VVGE><-6IjrIVu zh{kTQ^C}W3ua$IPrWlZ7Lly8SFbbGXbY^Ki(67k-#gb?N>N-0!_}>9Y0%2xtiRp7k zCPoLnK4^8o>O4DqlQ{qqK-3aFhv-pc+Vlr-hnL-hfTf-(a=I7M_i?JWn)QlPZ~9|E75*|(ViAQ2mGAsWReLHN^aij%-VpuPZ8l53xs z*(@5 zmPRfykV!J2HK;nE6Cj*2vwEKf4FR4=Xg;g%Q+kFH?tokP4TW$MCTvJo7u^%O8}tB1D*0@H3Qa48f0b-z{kE;1H4cP zF#t0=uGAi99P4gY!?LZ8XWU=#ARK9H_`cYUsGP~U~ ni87r9Ix$=5@BTI?=ZF6QbPQsgx;mAH00000NkvXXu0mjfMi9Vx literal 0 HcmV?d00001 diff --git a/electrum/gui/qml/components/AddressDetails.qml b/electrum/gui/qml/components/AddressDetails.qml new file mode 100644 index 000000000..7149685eb --- /dev/null +++ b/electrum/gui/qml/components/AddressDetails.qml @@ -0,0 +1,243 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string address + + property string title: qsTr("Address details") + + signal addressDetailsChanged + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Spend from') + //onTriggered: + icon.source: '../../icons/tab_send.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Sign/Verify') + icon.source: '../../icons/key.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Encrypt/Decrypt') + icon.source: '../../icons/mail_icon.png' + } + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Address') + Layout.columnSpan: 2 + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: root.address + font.family: FixedFont + Layout.fillWidth: true + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Address'), 'text': root.address }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Label') + Layout.columnSpan: 2 + } + + TextHighlightPane { + id: labelContent + + property bool editmode: false + + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + visible: !labelContent.editmode + text: addressdetails.label + wrapMode: Text.Wrap + Layout.fillWidth: true + } + ToolButton { + visible: !labelContent.editmode + icon.source: '../../icons/pen.png' + icon.color: 'transparent' + onClicked: { + labelEdit.text = addressdetails.label + labelContent.editmode = true + } + } + TextField { + id: labelEdit + visible: labelContent.editmode + text: addressdetails.label + Layout.fillWidth: true + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/confirmed.png' + icon.color: 'transparent' + onClicked: { + labelContent.editmode = false + addressdetails.set_label(labelEdit.text) + } + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/delete.png' + icon.color: 'transparent' + onClicked: labelContent.editmode = false + } + } + } + + Label { + text: qsTr('Public keys') + Layout.columnSpan: 2 + } + + Repeater { + model: addressdetails.pubkeys + delegate: TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + RowLayout { + width: parent.width + Label { + text: modelData + Layout.fillWidth: true + wrapMode: Text.Wrap + font.family: FixedFont + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Public key'), 'text': modelData }) + dialog.open() + } + } + } + } + } + + Label { + text: qsTr('Script type') + } + + Label { + text: addressdetails.scriptType + Layout.fillWidth: true + } + + Label { + text: qsTr('Balance') + } + + RowLayout { + Label { + font.family: FixedFont + text: Config.formatSats(addressdetails.balance) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(addressdetails.balance) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + + Label { + text: qsTr('Derivation path') + } + + Label { + text: addressdetails.derivationPath + } + + Label { + text: qsTr('Frozen') + } + + Label { + text: addressdetails.isFrozen ? qsTr('Frozen') : qsTr('Not frozen') + } + + ColumnLayout { + Layout.columnSpan: 2 + + Button { + text: addressdetails.isFrozen ? qsTr('Unfreeze') : qsTr('Freeze') + onClicked: addressdetails.freeze(!addressdetails.isFrozen) + } + } + } + } + + AddressDetails { + id: addressdetails + wallet: Daemon.currentWallet + address: root.address + onFrozenChanged: addressDetailsChanged() + onLabelChanged: addressDetailsChanged() + } + + Component { + id: share + GenericShareDialog {} + } +} diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index 51120b75c..111d47ee2 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -40,17 +40,13 @@ Pane { font.pixelSize: constants.fontSizeMedium // set default font size for child controls - onClicked: ListView.view.currentIndex == index - ? ListView.view.currentIndex = -1 - : ListView.view.currentIndex = index - - states: [ - State { - name: 'highlighted'; when: highlighted - PropertyChanges { target: drawer; visible: true } - PropertyChanges { target: labelLabel; maximumLineCount: 4 } - } - ] + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('AddressDetails.qml'), {'address': model.address}) + page.addressDetailsChanged.connect(function() { + // update listmodel when details change + listview.model.update_address(model.address) + }) + } ColumnLayout { id: delegateLayout @@ -83,7 +79,7 @@ Pane { Layout.preferredWidth: constants.iconSizeMedium Layout.preferredHeight: constants.iconSizeMedium color: model.held - ? Qt.rgba(1,0.93,0,0.75) + ? Qt.rgba(1,0,0,0.75) : model.numtx > 0 ? model.balance == 0 ? Qt.rgba(0.5,0.5,0.5,1) @@ -126,64 +122,6 @@ Pane { } } - RowLayout { - id: drawer - visible: false - Layout.fillWidth: true - Layout.preferredHeight: copyButton.height - - ToolButton { - id: copyButton - icon.source: '../../icons/copy.png' - icon.color: 'transparent' - icon.width: constants.iconSizeMedium - icon.height: constants.iconSizeMedium - onClicked: console.log('TODO: copy address') - } - ToolButton { - icon.source: '../../icons/info.png' - icon.color: 'transparent' - icon.width: constants.iconSizeMedium - icon.height: constants.iconSizeMedium - onClicked: console.log('TODO: show details screen') - } - ToolButton { - icon.source: '../../icons/key.png' - icon.color: 'transparent' - icon.width: constants.iconSizeMedium - icon.height: constants.iconSizeMedium - onClicked: console.log('TODO: sign/verify dialog') - } - ToolButton { - icon.source: '../../icons/mail_icon.png' - icon.color: 'transparent' - icon.width: constants.iconSizeMedium - icon.height: constants.iconSizeMedium - onClicked: console.log('TODO: encrypt/decrypt message dialog') - } - ToolButton { - icon.source: '../../icons/globe.png' - icon.color: 'transparent' - icon.width: constants.iconSizeMedium - icon.height: constants.iconSizeMedium - onClicked: console.log('TODO: show on block explorer') - } - ToolButton { - icon.source: '../../icons/unlock.png' - icon.color: 'transparent' - icon.width: constants.iconSizeMedium - icon.height: constants.iconSizeMedium - onClicked: console.log('TODO: freeze/unfreeze') - } - ToolButton { - icon.source: '../../icons/tab_send.png' - icon.color: 'transparent' - icon.width: constants.iconSizeMedium - icon.height: constants.iconSizeMedium - onClicked: console.log('TODO: spend from address') - } - } - Item { Layout.preferredWidth: 1 Layout.preferredHeight: constants.paddingSmall diff --git a/electrum/gui/qml/components/controls/GenericShareDialog.qml b/electrum/gui/qml/components/controls/GenericShareDialog.qml new file mode 100644 index 000000000..3df017071 --- /dev/null +++ b/electrum/gui/qml/components/controls/GenericShareDialog.qml @@ -0,0 +1,107 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +Dialog { + id: dialog + + property string text + + title: '' + parent: Overlay.overlay + modal: true + standardButtons: Dialog.Ok + + width: parent.width + height: parent.height + + Overlay.modal: Rectangle { + color: "#aa000000" + } + + header: RowLayout { + width: dialog.width + Label { + Layout.fillWidth: true + text: dialog.title + visible: dialog.title + elide: Label.ElideRight + padding: constants.paddingXLarge + bottomPadding: 0 + font.bold: true + font.pixelSize: constants.fontSizeMedium + } + } + + ColumnLayout { + id: rootLayout + width: parent.width + spacing: constants.paddingMedium + + Rectangle { + height: 1 + Layout.fillWidth: true + color: Material.accentColor + } + + Image { + id: qr + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingSmall + Layout.bottomMargin: constants.paddingSmall + + Rectangle { + property int size: 57 // should be qr pixel multiple + color: 'white' + x: (parent.width - size) / 2 + y: (parent.height - size) / 2 + width: size + height: size + + Image { + source: '../../../icons/electrum.png' + x: 1 + y: 1 + width: parent.width - 2 + height: parent.height - 2 + scale: 0.9 + } + } + } + + Rectangle { + height: 1 + Layout.fillWidth: true + color: Material.accentColor + } + + TextHighlightPane { + Layout.fillWidth: true + Label { + width: parent.width + text: dialog.text + wrapMode: Text.Wrap + } + } + + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + Button { + text: qsTr('Copy') + icon.source: '../../../icons/copy_bw.png' + onClicked: AppController.textToClipboard(dialog.text) + } + Button { + text: qsTr('Share') + icon.source: '../../../icons/share.png' + onClicked: console.log('TODO') + } + } + } + + Component.onCompleted: { + qr.source = 'image://qrgen/' + dialog.text + } +} diff --git a/electrum/gui/qml/components/controls/TextHighlightPane.qml b/electrum/gui/qml/components/controls/TextHighlightPane.qml new file mode 100644 index 000000000..9920d28c7 --- /dev/null +++ b/electrum/gui/qml/components/controls/TextHighlightPane.qml @@ -0,0 +1,10 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +Pane { + background: Rectangle { + color: Qt.lighter(Material.background, 1.15) + } +} diff --git a/electrum/gui/qml/qeaddressdetails.py b/electrum/gui/qml/qeaddressdetails.py new file mode 100644 index 000000000..6c78a4ded --- /dev/null +++ b/electrum/gui/qml/qeaddressdetails.py @@ -0,0 +1,113 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from decimal import Decimal + +from electrum.logging import get_logger +from electrum.util import DECIMAL_POINT_DEFAULT + +from .qetransactionlistmodel import QEAddressTransactionListModel +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QEAddressDetails(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _address = None + + _label = None + _frozen = False + _scriptType = None + _status = None + _balance = QEAmount() + _pubkeys = None + _privkey = None + _derivationPath = None + + _txlistmodel = None + + detailsChanged = pyqtSignal() + + 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.walletChanged.emit() + + addressChanged = pyqtSignal() + @pyqtProperty(str, notify=addressChanged) + def address(self): + return self._address + + @address.setter + def address(self, address: str): + if self._address != address: + self._logger.debug('address changed') + self._address = address + self.addressChanged.emit() + self.update() + + @pyqtProperty(str, notify=detailsChanged) + def scriptType(self): + return self._scriptType + + @pyqtProperty(QEAmount, notify=detailsChanged) + def balance(self): + return self._balance + + @pyqtProperty('QStringList', notify=detailsChanged) + def pubkeys(self): + return self._pubkeys + + @pyqtProperty(str, notify=detailsChanged) + def derivationPath(self): + return self._derivationPath + + + frozenChanged = pyqtSignal() + @pyqtProperty(bool, notify=frozenChanged) + def isFrozen(self): + return self._frozen + + labelChanged = pyqtSignal() + @pyqtProperty(str, notify=labelChanged) + def label(self): + return self._label + + @pyqtSlot(bool) + def freeze(self, freeze: bool): + if freeze != self._frozen: + self._wallet.wallet.set_frozen_state_of_addresses([self._address], freeze=freeze) + self._frozen = freeze + self.frozenChanged.emit() + + @pyqtSlot(str) + def set_label(self, label: str): + if label != self._label: + self._wallet.wallet.set_label(self._address, label) + self._label = label + self.labelChanged.emit() + + def update(self): + if self._wallet is None: + self._logger.error('wallet undefined') + return + + self._frozen = self._wallet.wallet.is_frozen_address(self._address) + self.frozenChanged.emit() + + self._scriptType = self._wallet.wallet.get_txin_type(self._address) + self._label = self._wallet.wallet.get_label(self._address) + c, u, x = self._wallet.wallet.get_addr_balance(self._address) + self._balance = QEAmount(amount_sat=c + u + x) + self._pubkeys = self._wallet.wallet.get_public_keys(self._address) + self._derivationPath = self._wallet.wallet.get_address_path_str(self._address) + self.detailsChanged.emit() diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index aa7722e8b..146ac9307 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -21,6 +21,7 @@ from .qefx import QEFX from .qetxfinalizer import QETxFinalizer from .qeinvoice import QEInvoice from .qetypes import QEAmount +from .qeaddressdetails import QEAddressDetails notification = None @@ -118,6 +119,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') + qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') From 15dec47516b70041bcd3366ea8036a075a295492 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 4 May 2022 15:31:01 +0200 Subject: [PATCH 131/218] compat with android/python3.8 --- electrum/gui/qml/qeaddresslistmodel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index 35bd3819b..4abe8a7f6 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -96,6 +96,6 @@ class QEAddressListModel(QAbstractListModel): def do_update(self, modelindex, modelitem): mi = self.createIndex(modelindex, 0) self._logger.debug(repr(modelitem)) - modelitem |= self.addr_to_model(modelitem['address']) + modelitem.update(self.addr_to_model(modelitem['address'])) self._logger.debug(repr(modelitem)) self.dataChanged.emit(mi, mi, self._ROLE_KEYS) From 7cd0d752a2d4c5bb1aa75158a9b6c77f713133df Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 5 May 2022 08:55:50 +0200 Subject: [PATCH 132/218] fiat and balance amount fixes --- electrum/gui/qml/components/Receive.qml | 4 +++- electrum/gui/qml/components/Send.qml | 4 +++- electrum/gui/qml/qefx.py | 4 ---- electrum/gui/qml/qewallet.py | 16 +++++++++------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 4703b3373..b04a89f5f 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -253,7 +253,9 @@ Pane { Connections { target: Daemon.fx function onQuotesUpdated() { - amountFiat.text = Daemon.fx.fiatValue(Config.unitsToSats(amount.text)) + amountFiat.text = amount.text == '' + ? '' + : Daemon.fx.fiatValue(Config.unitsToSats(amount.text)) } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index c3ab6cfdc..55de55518 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -268,7 +268,9 @@ Pane { Connections { target: Daemon.fx function onQuotesUpdated() { - amountFiat.text = Daemon.fx.fiatValue(Config.unitsToSats(amount.text)) + amountFiat.text = amount.text == '' + ? '' + : Daemon.fx.fiatValue(Config.unitsToSats(amount.text)) } } diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index 2102a2cdd..2de44e567 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -99,8 +99,6 @@ class QEFX(QObject): else: try: sd = Decimal(satoshis) - #if sd == 0: - #return '' except: return '' if plain: @@ -118,8 +116,6 @@ class QEFX(QObject): else: try: sd = Decimal(satoshis) - if sd == 0: - return '' except: return '' diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 2b499d97d..2aa2a7007 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -225,20 +225,22 @@ class QEWallet(QObject): balanceChanged = pyqtSignal() - @pyqtProperty('quint64', notify=balanceChanged) + @pyqtProperty(QEAmount, notify=balanceChanged) def frozenBalance(self): - return self.wallet.get_frozen_balance() + self._frozenbalance = QEAmount(amount_sat=self.wallet.get_frozen_balance()) + return self._frozenbalance - @pyqtProperty('quint64', notify=balanceChanged) + @pyqtProperty(QEAmount, notify=balanceChanged) def unconfirmedBalance(self): - return self.wallet.get_balance()[1] + self._unconfirmedbalance = QEAmount(amount_sat=self.wallet.get_balance()[1]) + return self._unconfirmedbalance - @pyqtProperty('quint64', notify=balanceChanged) + @pyqtProperty(QEAmount, notify=balanceChanged) def confirmedBalance(self): c, u, x = self.wallet.get_balance() self._logger.info('balance: ' + str(c) + ' ' + str(u) + ' ' + str(x) + ' ') - - return c+x + self._confirmedbalance = QEAmount(amount_sat=c+x) + return self._confirmedbalance @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): From 81435f431cafe691142ee2b49f849eccda1b9731 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 5 May 2022 09:20:33 +0200 Subject: [PATCH 133/218] use rbf flag, show tx outputs, actually send tx in confirmpaymentdialog --- .../qml/components/ConfirmPaymentDialog.qml | 33 +++++++++-- electrum/gui/qml/qetxfinalizer.py | 56 ++++++++++++++++--- electrum/gui/qml/qewallet.py | 2 + 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmPaymentDialog.qml b/electrum/gui/qml/components/ConfirmPaymentDialog.qml index 59f327113..fdcd91c7c 100644 --- a/electrum/gui/qml/components/ConfirmPaymentDialog.qml +++ b/electrum/gui/qml/components/ConfirmPaymentDialog.qml @@ -153,8 +153,34 @@ Dialog { CheckBox { id: final_cb - text: qsTr('Final') + text: qsTr('Replace-by-Fee') Layout.columnSpan: 2 + checked: finalizer.rbf + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + text: qsTr('Outputs') + Layout.columnSpan: 2 + } + + Repeater { + model: finalizer.outputs + delegate: RowLayout { + Layout.columnSpan: 2 + Label { + text: modelData.address + } + Label { + text: modelData.value_sats + } + } } Rectangle { @@ -179,10 +205,7 @@ Dialog { 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) + finalizer.send_onchain() } } } diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index a71199fe9..a49b97389 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -13,6 +13,7 @@ from .qetypes import QEAmount class QETxFinalizer(QObject): def __init__(self, parent=None): super().__init__(parent) + self._tx = None _logger = get_logger(__name__) @@ -27,6 +28,8 @@ class QETxFinalizer(QObject): _method = -1 _warning = '' _target = '' + _rbf = False + _outputs = [] config = None validChanged = pyqtSignal() @@ -103,6 +106,29 @@ class QETxFinalizer(QObject): self._target = target self.targetChanged.emit() + rbfChanged = pyqtSignal() + @pyqtProperty(bool, notify=rbfChanged) + def rbf(self): + return self._rbf + + @rbf.setter + def rbf(self, rbf): + if self._rbf != rbf: + self._rbf = rbf + self.update() + self.rbfChanged.emit() + + outputsChanged = pyqtSignal() + @pyqtProperty('QVariantList', notify=outputsChanged) + def outputs(self): + return self._outputs + + @outputs.setter + def outputs(self, outputs): + if self._outputs != outputs: + self._outputs = outputs + self.outputsChanged.emit() + warningChanged = pyqtSignal() @pyqtProperty(str, notify=warningChanged) def warning(self): @@ -163,7 +189,7 @@ class QETxFinalizer(QObject): self._method = (2 if mempool else 1) if dynfees else 0 self.update_slider() self.methodChanged.emit() - self.update(False) + self.update() def save_config(self): value = int(self._sliderPos) @@ -177,22 +203,26 @@ class QETxFinalizer(QObject): 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) + self.update() @profiler - def make_tx(self, rbf: bool): + def make_tx(self): coins = self._wallet.wallet.get_spendable_coins(None) outputs = [PartialTxOutput.from_address_and_value(self.address, self._amount.satsInt)] - tx = self._wallet.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None) + tx = self._wallet.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None,rbf=self._rbf) self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) + self._logger.debug(repr(tx.outputs())) + outputs = [] + for o in tx.outputs(): + outputs.append(o.to_json()) + self.outputs = outputs return tx - @pyqtSlot(bool) - def update(self, rbf): - #rbf = not bool(self.ids.final_cb.active) if self.show_final else False + @pyqtSlot() + def update(self): try: # make unsigned transaction - tx = self.make_tx(rbf) + tx = self.make_tx() except NotEnoughFunds: self.warning = _("Not enough funds") self._valid = False @@ -205,6 +235,8 @@ class QETxFinalizer(QObject): self.validChanged.emit() return + self._tx = tx + amount = self._amount.satsInt if not self._amount.isMax else tx.output_value() tx_size = tx.estimated_size() @@ -229,3 +261,11 @@ class QETxFinalizer(QObject): self._valid = True self.validChanged.emit() + + @pyqtSlot() + def send_onchain(self): + if not self._valid or not self._tx: + self._logger.debug('no valid tx') + return + + self._wallet.sign_and_broadcast(self._tx) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 2aa2a7007..1957fe4fe 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -264,7 +264,9 @@ class QEWallet(QObject): use_rbf = bool(self.wallet.config.get('use_rbf', True)) tx.set_rbf(use_rbf) + self.sign_and_broadcast(tx) + def sign_and_broadcast(self, tx): def cb(result): self._logger.info('signing was succesful? %s' % str(result)) tx = self.wallet.sign_transaction(tx, None) From 00430d674eb6a5c650fa2230aa1a686d010402eb Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 6 May 2022 10:29:30 +0200 Subject: [PATCH 134/218] font sizes, accent colors --- electrum/gui/qml/components/AddressDetails.qml | 12 ++++++++++++ electrum/gui/qml/components/History.qml | 5 +++-- .../qml/components/controls/GenericShareDialog.qml | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/AddressDetails.qml b/electrum/gui/qml/components/AddressDetails.qml index 7149685eb..51e008c82 100644 --- a/electrum/gui/qml/components/AddressDetails.qml +++ b/electrum/gui/qml/components/AddressDetails.qml @@ -58,6 +58,7 @@ Pane { Label { text: qsTr('Address') Layout.columnSpan: 2 + color: Material.accentColor } TextHighlightPane { @@ -70,8 +71,10 @@ Pane { width: parent.width Label { text: root.address + font.pixelSize: constants.fontSizeLarge font.family: FixedFont Layout.fillWidth: true + wrapMode: Text.Wrap } ToolButton { icon.source: '../../icons/share.png' @@ -87,6 +90,7 @@ Pane { Label { text: qsTr('Label') Layout.columnSpan: 2 + color: Material.accentColor } TextHighlightPane { @@ -106,6 +110,7 @@ Pane { text: addressdetails.label wrapMode: Text.Wrap Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge } ToolButton { visible: !labelContent.editmode @@ -120,6 +125,7 @@ Pane { id: labelEdit visible: labelContent.editmode text: addressdetails.label + font.pixelSize: constants.fontSizeLarge Layout.fillWidth: true } ToolButton { @@ -143,6 +149,7 @@ Pane { Label { text: qsTr('Public keys') Layout.columnSpan: 2 + color: Material.accentColor } Repeater { @@ -158,6 +165,7 @@ Pane { text: modelData Layout.fillWidth: true wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge font.family: FixedFont } ToolButton { @@ -174,6 +182,7 @@ Pane { Label { text: qsTr('Script type') + color: Material.accentColor } Label { @@ -183,6 +192,7 @@ Pane { Label { text: qsTr('Balance') + color: Material.accentColor } RowLayout { @@ -203,6 +213,7 @@ Pane { Label { text: qsTr('Derivation path') + color: Material.accentColor } Label { @@ -211,6 +222,7 @@ Pane { Label { text: qsTr('Frozen') + color: Material.accentColor } Label { diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 8f1d05dca..fd3b93ef1 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -93,10 +93,10 @@ Pane { } Label { - font.pixelSize: constants.fontSizeLarge Layout.fillWidth: true + font.pixelSize: model.label !== '' ? constants.fontSizeLarge : constants.fontSizeMedium text: model.label !== '' ? model.label : '' - color: model.label !== '' ? Material.accentColor : 'gray' + color: model.label !== '' ? Material.foreground : 'gray' wrapMode: Text.Wrap maximumLineCount: 2 elide: Text.ElideRight @@ -117,6 +117,7 @@ Pane { Label { font.pixelSize: constants.fontSizeSmall text: model.date + color: Material.accentColor } Label { id: fiatLabel diff --git a/electrum/gui/qml/components/controls/GenericShareDialog.qml b/electrum/gui/qml/components/controls/GenericShareDialog.qml index 3df017071..4e6f383e2 100644 --- a/electrum/gui/qml/components/controls/GenericShareDialog.qml +++ b/electrum/gui/qml/components/controls/GenericShareDialog.qml @@ -82,6 +82,8 @@ Dialog { width: parent.width text: dialog.text wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont } } From b188b48e2f17f2461c1855defbf544655859dfa4 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 6 May 2022 11:00:19 +0200 Subject: [PATCH 135/218] after-rebase fixes --- .../gui/qml/components/controls/InvoiceDelegate.qml | 4 +++- electrum/gui/qml/qeaddressdetails.py | 1 - electrum/gui/qml/qebitcoin.py | 5 ++--- electrum/gui/qml/qeinvoice.py | 12 ++++++++---- electrum/gui/qml/qeinvoicelistmodel.py | 13 +++++-------- electrum/gui/qml/qeqr.py | 5 ++--- electrum/gui/qml/qewallet.py | 5 +++-- 7 files changed, 23 insertions(+), 22 deletions(-) diff --git a/electrum/gui/qml/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml index 00b14bf2a..0c445e2c1 100644 --- a/electrum/gui/qml/components/controls/InvoiceDelegate.qml +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -33,7 +33,9 @@ ItemDelegate { Layout.rowSpan: 2 Layout.preferredWidth: constants.iconSizeLarge Layout.preferredHeight: constants.iconSizeLarge - source: model.type == 0 ? "../../../icons/bitcoin.png" : "../../../icons/lightning.png" + source: model.is_lightning + ? "../../../icons/lightning.png" + : "../../../icons/bitcoin.png" } RowLayout { diff --git a/electrum/gui/qml/qeaddressdetails.py b/electrum/gui/qml/qeaddressdetails.py index 6c78a4ded..422aed402 100644 --- a/electrum/gui/qml/qeaddressdetails.py +++ b/electrum/gui/qml/qeaddressdetails.py @@ -5,7 +5,6 @@ from decimal import Decimal from electrum.logging import get_logger from electrum.util import DECIMAL_POINT_DEFAULT -from .qetransactionlistmodel import QEAddressTransactionListModel from .qewallet import QEWallet from .qetypes import QEAmount diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index e001a509e..2dbfaf34e 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -8,7 +8,7 @@ from electrum.keystore import bip39_is_checksum_valid from electrum.bip32 import is_bip32_derivation from electrum.slip39 import decode_mnemonic, Slip39Error from electrum import mnemonic -from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI +from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI, get_asyncio_loop from .qetypes import QEAmount @@ -58,8 +58,7 @@ class QEBitcoin(QObject): self._logger.debug('seed generated') self.generatedSeedChanged.emit() - loop = asyncio.get_event_loop() - asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), loop) + asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), get_asyncio_loop()) @pyqtSlot(str) @pyqtSlot(str,bool,bool) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index c8133e266..ea25bc1f3 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -8,7 +8,7 @@ from electrum.i18n import _ from electrum.keystore import bip39_is_checksum_valid from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, maybe_extract_bolt11_invoice) -from electrum.invoices import Invoice, OnchainInvoice, LNInvoice +from electrum.invoices import Invoice from electrum.invoices import (PR_UNPAID,PR_EXPIRED,PR_UNKNOWN,PR_PAID,PR_INFLIGHT, PR_FAILED,PR_ROUTING,PR_UNCONFIRMED) from electrum.transaction import PartialTxOutput @@ -164,12 +164,16 @@ class QEInvoice(QObject): self._effectiveInvoice = None ###TODO self.invoiceChanged.emit() - def setValidOnchainInvoice(self, invoice: OnchainInvoice): + def setValidOnchainInvoice(self, invoice: Invoice): self._logger.debug('setValidOnchainInvoice') + if invoice.is_lightning(): + raise Exception('unexpected LN invoice') self.set_effective_invoice(invoice) - def setValidLightningInvoice(self, invoice: LNInvoice): + def setValidLightningInvoice(self, invoice: Invoice): self._logger.debug('setValidLightningInvoice') + if not invoice.is_lightning(): + raise Exception('unexpected Onchain invoice') self.set_effective_invoice(invoice) def create_onchain_invoice(self, outputs, message, payment_request, uri): @@ -215,7 +219,7 @@ class QEInvoice(QObject): lninvoice = None try: maybe_lightning_invoice = maybe_extract_bolt11_invoice(maybe_lightning_invoice) - lninvoice = LNInvoice.from_bech32(maybe_lightning_invoice) + lninvoice = Invoice.from_bech32(maybe_lightning_invoice) except InvoiceError as e: pass diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 00b5d339c..d0d2ba000 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -18,7 +18,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): self.invoices = [] # define listmodel rolemap - _ROLE_NAMES=('key','type','timestamp','date','message','amount','status','status_str','address','expiration') + _ROLE_NAMES=('key','is_lightning','timestamp','date','message','amount','status','status_str','address','expiration') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -113,13 +113,10 @@ class QEInvoiceListModel(QEAbstractInvoiceListModel): def invoice_to_model(self, invoice: Invoice): item = self.wallet.export_invoice(invoice) - item['type'] = invoice.type # 0=onchain, 2=LN + item['is_lightning'] = invoice.is_lightning() item['date'] = format_time(item['timestamp']) item['amount'] = QEAmount(amount_sat=invoice.get_amount_sat()) - if invoice.type == 0: - item['key'] = invoice.id - elif invoice.type == 2: - item['key'] = invoice.rhash + item['key'] = invoice.get_id() return item @@ -137,8 +134,8 @@ class QERequestListModel(QEAbstractInvoiceListModel): def invoice_to_model(self, req: Invoice): item = self.wallet.export_request(req) - item['key'] = self.wallet.get_key_for_receive_request(req) - item['type'] = req.type # 0=onchain, 2=LN + item['key'] = req.get_id() #self.wallet.get_key_for_receive_request(req) + item['is_lightning'] = req.is_lightning() item['date'] = format_time(item['timestamp']) item['amount'] = QEAmount(amount_sat=req.get_amount_sat()) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index 6d7204ffb..d2e23f347 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -10,7 +10,7 @@ from PIL import Image, ImageQt from electrum.logging import get_logger from electrum.qrreader import get_qr_reader from electrum.i18n import _ -from electrum.util import profiler +from electrum.util import profiler, get_asyncio_loop class QEQRParser(QObject): def __init__(self, text=None, parent=None): @@ -81,8 +81,7 @@ class QEQRParser(QObject): self._busy = False self.busyChanged.emit() - loop = asyncio.get_event_loop() - asyncio.run_coroutine_threadsafe(co_parse_qr(frame_cropped), loop) + asyncio.run_coroutine_threadsafe(co_parse_qr(frame_cropped), get_asyncio_loop()) def _get_crop(self, image: QImage, scan_size: int) -> QRect: """ diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 1957fe4fe..93c17471f 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -10,8 +10,9 @@ from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum import bitcoin from electrum.transaction import PartialTxOutput -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 electrum.invoices import (Invoice, InvoiceError, + PR_DEFAULT_EXPIRATION_WHEN_CREATING, PR_PAID, + PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED) from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel from .qetransactionlistmodel import QETransactionListModel From 4cc3acabb31a3de490ec6b9ee8cab2496081b793 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 6 May 2022 11:18:17 +0200 Subject: [PATCH 136/218] colors in history --- electrum/gui/qml/components/Constants.qml | 2 +- electrum/gui/qml/components/History.qml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index 5aee068ed..01c0865e9 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -24,5 +24,5 @@ Item { property color colorCredit: "#ff80ff80" property color colorDebit: "#ffff8080" - property color mutedForeground: Qt.lighter(Material.background, 2) + property color mutedForeground: 'gray' //Qt.lighter(Material.background, 2) } diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index fd3b93ef1..e94388919 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -36,7 +36,7 @@ Pane { Layout.alignment: Qt.AlignHCenter Layout.topMargin: constants.paddingLarge font.pixelSize: constants.fontSizeLarge - color: constants.mutedForeground + color: Material.accentColor } } @@ -96,7 +96,7 @@ Pane { Layout.fillWidth: true font.pixelSize: model.label !== '' ? constants.fontSizeLarge : constants.fontSizeMedium text: model.label !== '' ? model.label : '' - color: model.label !== '' ? Material.foreground : 'gray' + color: model.label !== '' ? Material.foreground : constants.mutedForeground wrapMode: Text.Wrap maximumLineCount: 2 elide: Text.ElideRight @@ -117,7 +117,7 @@ Pane { Label { font.pixelSize: constants.fontSizeSmall text: model.date - color: Material.accentColor + color: constants.mutedForeground } Label { id: fiatLabel From 52c1ed10dc9783ea0ba9602fb2593c992e6048ea Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 6 May 2022 12:02:17 +0200 Subject: [PATCH 137/218] add section dependent datetime formatting --- electrum/gui/qml/qetransactionlistmodel.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index f4243a16a..dca90bf9a 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -74,8 +74,20 @@ class QETransactionListModel(QAbstractListModel): else: item['section'] = 'older' + item['date'] = self.format_date_by_section(item['section'], datetime.fromtimestamp(item['timestamp'])) return item + def format_date_by_section(self, section, date): + #TODO: l10n + dfmt = { + 'today': '%H:%M:%S', + 'yesterday': '%H:%M:%S', + 'lastweek': '%a, %H:%M:%S', + 'lastmonth': '%a %d, %H:%M:%S', + 'older': '%G-%m-%d %H:%M:%S' + }[section] + return date.strftime(dfmt) + # initial model data def init_model(self): history = self.wallet.get_detailed_history(show_addresses = True) @@ -96,7 +108,7 @@ class QETransactionListModel(QAbstractListModel): tx['height'] = info.height tx['confirmations'] = info.conf tx['timestamp'] = info.timestamp - tx['date'] = datetime.fromtimestamp(info.timestamp) + tx['date'] = self.format_date_by_section(datetime.fromtimestamp(info.timestamp), tx['section']) index = self.index(i,0) roles = [self._ROLE_RMAP[x] for x in ['height','confirmations','timestamp','date']] self.dataChanged.emit(index, index, roles) From e1f53c4ea094890c6c1a5daf4010054be144f8dc Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 6 May 2022 15:36:36 +0200 Subject: [PATCH 138/218] QEDaemon uses internal QEWalletDB for wallet open pre-checks various other fixes --- electrum/gui/qml/components/Wallets.qml | 14 ++++------- electrum/gui/qml/qeaddressdetails.py | 1 + electrum/gui/qml/qedaemon.py | 28 ++++++++++++++-------- electrum/gui/qml/qetransactionlistmodel.py | 6 +++-- electrum/gui/qml/qewallet.py | 4 ++-- electrum/gui/qml/qewalletdb.py | 15 +++++++----- 6 files changed, 38 insertions(+), 30 deletions(-) diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index b131953fe..1e6156a32 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -30,8 +30,8 @@ Pane { Label { text: 'Wallet'; Layout.columnSpan: 2 } Label { text: Daemon.currentWallet.name; Layout.columnSpan: 2; color: Material.accentColor } - Label { text: 'derivation path (BIP32)'; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } - Label { text: Daemon.currentWallet.derivationPath; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } + Label { text: 'derivation prefix (BIP32)'; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } + Label { text: Daemon.currentWallet.derivationPrefix; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } Label { text: 'txinType' } Label { text: Daemon.currentWallet.txinType; color: Material.accentColor } @@ -69,9 +69,6 @@ Pane { delegate: AbstractButton { width: ListView.view.width height: row.height - onClicked: { - wallet_db.path = model.path - } RowLayout { id: row @@ -83,8 +80,8 @@ Pane { id: walleticon source: "../../icons/wallet.png" fillMode: Image.PreserveAspectFit - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge } Label { @@ -129,7 +126,4 @@ Pane { } } - WalletDB { - id: wallet_db - } } diff --git a/electrum/gui/qml/qeaddressdetails.py b/electrum/gui/qml/qeaddressdetails.py index 422aed402..9bf20cb37 100644 --- a/electrum/gui/qml/qeaddressdetails.py +++ b/electrum/gui/qml/qeaddressdetails.py @@ -109,4 +109,5 @@ class QEAddressDetails(QObject): self._balance = QEAmount(amount_sat=c + u + x) self._pubkeys = self._wallet.wallet.get_public_keys(self._address) self._derivationPath = self._wallet.wallet.get_address_path_str(self._address) + self._derivationPath = self._derivationPath.replace('m', self._wallet.derivationPrefix) self.detailsChanged.emit() diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 113baf938..8d049b31a 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -8,8 +8,10 @@ from electrum.util import register_callback, get_new_wallet_name, WalletFileExce from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import WalletStorage, StorageReadWriteError +from electrum.wallet_db import WalletDB from .qewallet import QEWallet +from .qewalletdb import QEWalletDB from .qefx import QEFX # wallet list model. supports both wallet basenames (wallet file basenames) @@ -87,6 +89,8 @@ class QEDaemon(QObject): super().__init__(parent) self.daemon = daemon self.qefx = QEFX(daemon.fx, daemon.config) + self._walletdb = QEWalletDB() + self._walletdb.invalidPasswordChanged.connect(self.passwordValidityCheck) _logger = get_logger(__name__) _loaded_wallets = QEWalletListModel() @@ -94,6 +98,7 @@ class QEDaemon(QObject): _current_wallet = None _path = None + walletLoaded = pyqtSignal() walletRequiresPassword = pyqtSignal() activeWalletsChanged = pyqtSignal() @@ -101,6 +106,11 @@ class QEDaemon(QObject): walletOpenError = pyqtSignal([str], arguments=["error"]) fxChanged = pyqtSignal() + @pyqtSlot() + def passwordValidityCheck(self): + if self._walletdb._invalidPassword: + self.walletRequiresPassword.emit() + @pyqtSlot() @pyqtSlot(str) @pyqtSlot(str, str) @@ -113,14 +123,13 @@ class QEDaemon(QObject): return self._logger.debug('load wallet ' + str(self._path)) - try: - storage = WalletStorage(self._path) - if not storage.file_exists(): - self.walletOpenError.emit('File not found') + + if path not in self.daemon._wallets: + # pre-checks, let walletdb trigger any necessary user interactions + self._walletdb.path = self._path + self._walletdb.password = password + if not self._walletdb.ready: return - except StorageReadWriteError as e: - self.walletOpenError.emit('Storage read/write error') - return try: wallet = self.daemon.load_wallet(self._path, password) @@ -130,8 +139,8 @@ class QEDaemon(QObject): self.walletLoaded.emit() self.daemon.config.save_last_wallet(wallet) else: - self._logger.info('password required but unset or incorrect') - self.walletRequiresPassword.emit() + self._logger.info('could not open wallet') + self.walletOpenError.emit('could not open wallet') except WalletFileException as e: self._logger.error(str(e)) self.walletOpenError.emit(str(e)) @@ -159,4 +168,3 @@ class QEDaemon(QObject): @pyqtProperty(QEFX, notify=fxChanged) def fx(self): return self.qefx - diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index dca90bf9a..22ec81643 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -85,8 +85,10 @@ class QETransactionListModel(QAbstractListModel): 'lastweek': '%a, %H:%M:%S', 'lastmonth': '%a %d, %H:%M:%S', 'older': '%G-%m-%d %H:%M:%S' - }[section] - return date.strftime(dfmt) + } + if section not in dfmt: + section = 'older' + return date.strftime(dfmt[section]) # initial model data def init_model(self): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 93c17471f..ddf6138d5 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -139,7 +139,7 @@ class QEWallet(QObject): self._logger.debug('queue empty, stopping wallet notification timer') self.notification_timer.stop() return - if not self.wallet.up_to_date: + if not self.wallet.is_up_to_date(): return # no notifications while syncing now = time.time() rate_limit = 20 # seconds @@ -218,7 +218,7 @@ class QEWallet(QObject): return self.wallet.storage.is_encrypted_with_hw_device() @pyqtProperty('QString', notify=dataChanged) - def derivationPath(self): + def derivationPrefix(self): keystores = self.wallet.get_keystores() if len(keystores) > 1: self._logger.debug('multiple keystores not supported yet') diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index 34000d77a..d3dae8e16 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -9,8 +9,6 @@ from electrum.bip32 import normalize_bip32_derivation from electrum.util import InvalidPassword from electrum import keystore -from .qedaemon import QEDaemon - class QEWalletDB(QObject): def __init__(self, parent=None): super().__init__(parent) @@ -116,6 +114,12 @@ class QEWalletDB(QObject): def invalidPassword(self): return self._invalidPassword + @invalidPassword.setter + def invalidPassword(self, invalidPassword): + if self._invalidPassword != invalidPassword: + self._invalidPassword = invalidPassword + self.invalidPasswordChanged.emit() + @pyqtProperty(bool, notify=readyChanged) def ready(self): return self._ready @@ -143,11 +147,10 @@ class QEWalletDB(QObject): self.needsPassword = True try: - self._storage.decrypt(self._password) - self._invalidPassword = False + self._storage.decrypt('' if not self._password else self._password) + self.invalidPassword = False except InvalidPassword as e: - self._invalidPassword = True - self.invalidPasswordChanged.emit() + self.invalidPassword = True if not self._storage.is_past_initial_decryption(): self._storage = None From a584c06eb2fabc19fff3944926383d6bea4a882f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 6 May 2022 17:09:43 +0200 Subject: [PATCH 139/218] more rebase fixes, add invoice delete --- .../gui/qml/components/BalanceSummary.qml | 4 +-- .../qml/components/ConfirmInvoiceDialog.qml | 9 +++++++ electrum/gui/qml/components/RequestDialog.qml | 10 +++---- .../controls/GenericShareDialog.qml | 1 + .../components/controls/InvoiceDelegate.qml | 6 ++++- electrum/gui/qml/qeinvoicelistmodel.py | 9 +++++-- electrum/gui/qml/qewallet.py | 26 ++++++++++--------- 7 files changed, 43 insertions(+), 22 deletions(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index edf0c00ec..03d934a20 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -17,8 +17,8 @@ Frame { root.formattedBalance = Config.formatSats(Daemon.currentWallet.confirmedBalance) root.formattedUnconfirmed = Config.formatSats(Daemon.currentWallet.unconfirmedBalance) if (Daemon.fx.enabled) { - root.formattedBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.confirmedBalance.toString(), false) - root.formattedUnconfirmedFiat = Daemon.fx.fiatValue(Daemon.currentWallet.unconfirmedBalance.toString(), false) + root.formattedBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.confirmedBalance, false) + root.formattedUnconfirmedFiat = Daemon.fx.fiatValue(Daemon.currentWallet.unconfirmedBalance, false) } } diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml index 19cc51bd2..6a18bc583 100644 --- a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml +++ b/electrum/gui/qml/components/ConfirmInvoiceDialog.qml @@ -105,6 +105,15 @@ Dialog { Layout.alignment: Qt.AlignHCenter spacing: constants.paddingMedium + Button { + text: qsTr('Delete') + visible: invoice_key != '' + onClicked: { + invoice.wallet.delete_invoice(invoice_key) + dialog.close() + } + } + Button { text: qsTr('Cancel') onClicked: dialog.close() diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 614595a5f..c12da6026 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -130,17 +130,17 @@ Dialog { } Label { - visible: modelItem.amount > 0 + visible: modelItem.amount != 0 text: qsTr('Amount') } Label { - visible: modelItem.amount > 0 + visible: modelItem.amount != 0 text: Config.formatSats(modelItem.amount) font.family: FixedFont font.pixelSize: constants.fontSizeLarge } Label { - visible: modelItem.amount > 0 + visible: modelItem.amount != 0 text: Config.baseUnit color: Material.accentColor font.pixelSize: constants.fontSizeLarge @@ -148,7 +148,7 @@ Dialog { Label { id: fiatValue - visible: modelItem.amount > 0 + visible: modelItem.amount != 0 Layout.fillWidth: true Layout.columnSpan: 2 text: Daemon.fx.enabled @@ -199,7 +199,7 @@ Dialog { } Component.onCompleted: { - _bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration) + _bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp) qr.source = 'image://qrgen/' + _bip21uri } diff --git a/electrum/gui/qml/components/controls/GenericShareDialog.qml b/electrum/gui/qml/components/controls/GenericShareDialog.qml index 4e6f383e2..9ef834b74 100644 --- a/electrum/gui/qml/components/controls/GenericShareDialog.qml +++ b/electrum/gui/qml/components/controls/GenericShareDialog.qml @@ -96,6 +96,7 @@ Dialog { onClicked: AppController.textToClipboard(dialog.text) } Button { + enabled: false text: qsTr('Share') icon.source: '../../../icons/share.png' onClicked: console.log('TODO') diff --git a/electrum/gui/qml/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml index 0c445e2c1..67e9c0239 100644 --- a/electrum/gui/qml/components/controls/InvoiceDelegate.qml +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -42,7 +42,11 @@ ItemDelegate { Layout.fillWidth: true Label { Layout.fillWidth: true - text: model.message ? model.message : model.address + text: model.message + ? model.message + : model.type == 'request' + ? model.address + : '' elide: Text.ElideRight wrapMode: Text.Wrap maximumLineCount: 2 diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index d0d2ba000..52e8d304d 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -18,7 +18,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): self.invoices = [] # define listmodel rolemap - _ROLE_NAMES=('key','is_lightning','timestamp','date','message','amount','status','status_str','address','expiration') + _ROLE_NAMES=('key','is_lightning','timestamp','date','message','amount','status','status_str','address','expiration','type') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -88,6 +88,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): item['status_str'] = invoice.get_status_str(status) index = self.index(i,0) self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']]) + return i = i + 1 @abstractmethod @@ -118,6 +119,8 @@ class QEInvoiceListModel(QEAbstractInvoiceListModel): item['amount'] = QEAmount(amount_sat=invoice.get_amount_sat()) item['key'] = invoice.get_id() + item['type'] = 'invoice' + return item def get_invoice_for_key(self, key: str): @@ -134,11 +137,13 @@ class QERequestListModel(QEAbstractInvoiceListModel): def invoice_to_model(self, req: Invoice): item = self.wallet.export_request(req) - item['key'] = req.get_id() #self.wallet.get_key_for_receive_request(req) + item['key'] = req.get_rhash() if req.is_lightning() else req.get_address() item['is_lightning'] = req.is_lightning() item['date'] = format_time(item['timestamp']) item['amount'] = QEAmount(amount_sat=req.get_amount_sat()) + item['type'] = 'request' + return item def get_invoice_for_key(self, key: str): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index ddf6138d5..09e4820ce 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -311,18 +311,18 @@ class QEWallet(QObject): 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('fatal',_('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_invoice(req) - return addr + req_key = self.wallet.create_request(amount, message, expiration, addr, False) + #try: + #self.wallet.add_payment_request(req) + #except Exception as e: + #self.logger.exception('Error adding payment request') + #self.requestCreateError.emit('fatal',_('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_invoice(self.wallet.get_request(req_key)) + #return addr @pyqtSlot(QEAmount, 'QString', int) @pyqtSlot(QEAmount, 'QString', int, bool) @@ -350,6 +350,7 @@ class QEWallet(QObject): @pyqtSlot('QString') def delete_request(self, key: str): + self._logger.debug('delete req %s' % key) self.wallet.delete_request(key) self._requestModel.delete_invoice(key) @@ -360,6 +361,7 @@ class QEWallet(QObject): @pyqtSlot('QString') def delete_invoice(self, key: str): + self._logger.debug('delete inv %s' % key) self.wallet.delete_invoice(key) self._invoiceModel.delete_invoice(key) From e78a239bf59103fba63a1deb8f4e5b2abf2a1c0d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 6 May 2022 20:27:07 +0200 Subject: [PATCH 140/218] bugfixes, lifecycle improvements --- electrum/gui/qml/components/Addresses.qml | 4 ++-- .../gui/qml/components/ConfirmPaymentDialog.qml | 1 + electrum/gui/qml/components/Receive.qml | 4 +++- electrum/gui/qml/components/RequestDialog.qml | 10 +++++----- .../qml/components/controls/NotificationPopup.qml | 12 ++++++++---- electrum/gui/qml/components/main.qml | 12 ++++++------ electrum/gui/qml/qeaddresslistmodel.py | 6 ++++-- electrum/gui/qml/qeinvoicelistmodel.py | 11 ++++++++--- electrum/gui/qml/qetransactionlistmodel.py | 4 ++-- electrum/gui/qml/qewallet.py | 13 +++++-------- 10 files changed, 44 insertions(+), 33 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index 111d47ee2..c58d237a0 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -103,12 +103,12 @@ Pane { Label { font.family: FixedFont text: Config.formatSats(model.balance, false) - visible: model.balance > 0 + visible: model.balance.satsInt != 0 } Label { color: Material.accentColor text: Config.baseUnit + ',' - visible: model.balance > 0 + visible: model.balance.satsInt != 0 } Label { text: model.numtx diff --git a/electrum/gui/qml/components/ConfirmPaymentDialog.qml b/electrum/gui/qml/components/ConfirmPaymentDialog.qml index fdcd91c7c..a0f04cd2b 100644 --- a/electrum/gui/qml/components/ConfirmPaymentDialog.qml +++ b/electrum/gui/qml/components/ConfirmPaymentDialog.qml @@ -206,6 +206,7 @@ Dialog { enabled: finalizer.valid onClicked: { finalizer.send_onchain() + dialog.close() } } } diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index b04a89f5f..b6648001e 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -215,7 +215,9 @@ Pane { Component { id: requestdialog - RequestDialog {} + RequestDialog { + onClosed: destroy() + } } function createRequest(ignoreGaplimit = false) { diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index c12da6026..0d5bd9e48 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -130,17 +130,17 @@ Dialog { } Label { - visible: modelItem.amount != 0 + visible: modelItem.amount.satsInt != 0 text: qsTr('Amount') } Label { - visible: modelItem.amount != 0 + visible: modelItem.amount.satsInt != 0 text: Config.formatSats(modelItem.amount) font.family: FixedFont font.pixelSize: constants.fontSizeLarge } Label { - visible: modelItem.amount != 0 + visible: modelItem.amount.satsInt != 0 text: Config.baseUnit color: Material.accentColor font.pixelSize: constants.fontSizeLarge @@ -148,7 +148,7 @@ Dialog { Label { id: fiatValue - visible: modelItem.amount != 0 + visible: modelItem.amount.satsInt != 0 Layout.fillWidth: true Layout.columnSpan: 2 text: Daemon.fx.enabled @@ -191,7 +191,7 @@ Dialog { Connections { target: Daemon.currentWallet - function onRequestStatusChanged(key, code) { + function onRequestStatusChanged(key, status) { if (key != modelItem.key) return modelItem = Daemon.currentWallet.get_request(key) diff --git a/electrum/gui/qml/components/controls/NotificationPopup.qml b/electrum/gui/qml/components/controls/NotificationPopup.qml index ed6595bd4..adff81c09 100644 --- a/electrum/gui/qml/components/controls/NotificationPopup.qml +++ b/electrum/gui/qml/components/controls/NotificationPopup.qml @@ -32,14 +32,22 @@ Rectangle { } ] + function show(message) { + root.text = message + root.hide = false + closetimer.start() + } + RowLayout { id: layout width: parent.width Text { id: textItem Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true font.pixelSize: constants.fontSizeLarge color: Material.foreground + wrapMode: Text.Wrap } } @@ -50,8 +58,4 @@ Rectangle { onTriggered: hide = true } - Component.onCompleted: { - hide = false - closetimer.start() - } } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 40f73b3b7..5d438d24a 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -174,13 +174,13 @@ ApplicationWindow property alias messageDialog: _messageDialog Component { id: _messageDialog - MessageDialog {} + MessageDialog { + onClosed: destroy() + } } - property alias notificationPopup: _notificationPopup - Component { - id: _notificationPopup - NotificationPopup {} + NotificationPopup { + id: notificationPopup } Component.onCompleted: { @@ -226,7 +226,7 @@ ApplicationWindow Connections { target: AppController function onUserNotify(message) { - var item = app.notificationPopup.createObject(app, {'text': message}) + notificationPopup.show(message) } } } diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index 4abe8a7f6..eb042b8f2 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -4,6 +4,8 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.logging import get_logger from electrum.util import Satoshis +from .qetypes import QEAmount + class QEAddressListModel(QAbstractListModel): def __init__(self, wallet, parent=None): super().__init__(parent) @@ -32,7 +34,7 @@ class QEAddressListModel(QAbstractListModel): address = self.receive_addresses[index.row()] role_index = role - Qt.UserRole value = address[self._ROLE_NAMES[role_index]] - if isinstance(value, bool) or isinstance(value, list) or isinstance(value, int) or value is None: + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: return value if isinstance(value, Satoshis): return value.value @@ -50,7 +52,7 @@ class QEAddressListModel(QAbstractListModel): item['numtx'] = self.wallet.get_address_history_len(address) item['label'] = self.wallet.get_label(address) c, u, x = self.wallet.get_addr_balance(address) - item['balance'] = c + u + x + item['balance'] = QEAmount(amount_sat=c + u + x) item['held'] = self.wallet.is_frozen_address(address) return item diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 52e8d304d..91eec07ad 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -33,12 +33,11 @@ class QEAbstractInvoiceListModel(QAbstractListModel): invoice = self.invoices[index.row()] role_index = role - Qt.UserRole value = invoice[self._ROLE_NAMES[role_index]] - if isinstance(value, bool) or isinstance(value, list) or isinstance(value, int) or value is None: + + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: return value if isinstance(value, Satoshis): return value.value - if isinstance(value, QEAmount): - return value return str(value) def clear(self): @@ -77,6 +76,12 @@ class QEAbstractInvoiceListModel(QAbstractListModel): break i = i + 1 + def get_model_invoice(self, key: str): + for invoice in self.invoices: + if invoice['key'] == key: + return invoice + return None + @pyqtSlot(str, int) def updateInvoice(self, key, status): self._logger.debug('updating invoice for %s to %d' % (key,status)) diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 22ec81643..3426b0488 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -34,7 +34,7 @@ class QETransactionListModel(QAbstractListModel): tx = self.tx_history[index.row()] role_index = role - Qt.UserRole value = tx[self._ROLE_NAMES[role_index]] - if isinstance(value, bool) or isinstance(value, list) or isinstance(value, int) or value is None: + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: return value if isinstance(value, Satoshis): return value.value @@ -110,7 +110,7 @@ class QETransactionListModel(QAbstractListModel): tx['height'] = info.height tx['confirmations'] = info.conf tx['timestamp'] = info.timestamp - tx['date'] = self.format_date_by_section(datetime.fromtimestamp(info.timestamp), tx['section']) + tx['date'] = self.format_date_by_section(tx['section'], datetime.fromtimestamp(info.timestamp)) index = self.index(i,0) roles = [self._ROLE_RMAP[x] for x in ['height','confirmations','timestamp','date']] self.dataChanged.emit(index, index, roles) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 09e4820ce..a1f2cf3a9 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -322,22 +322,21 @@ class QEWallet(QObject): #pass ##self.sign_payment_request(addr) self._requestModel.add_invoice(self.wallet.get_request(req_key)) - #return addr + return addr @pyqtSlot(QEAmount, 'QString', int) @pyqtSlot(QEAmount, 'QString', int, bool) @pyqtSlot(QEAmount, 'QString', int, bool, bool) def create_request(self, amount: QEAmount, message: str, expiration: int, is_lightning: bool = False, ignore_gap: 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.requestCreateError.emit('fatal',_("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.satsInt, message, expiry) + key = self.wallet.lnworker.add_request(amount.satsInt, message, expiration) else: - key = self.create_bitcoin_request(amount.satsInt, message, expiry, ignore_gap) + key = self.create_bitcoin_request(amount.satsInt, message, expiration, ignore_gap) if not key: return self._addressModel.init_model() @@ -356,8 +355,7 @@ class QEWallet(QObject): @pyqtSlot('QString', result='QVariant') def get_request(self, key: str): - req = self.wallet.get_request(key) - return self._requestModel.invoice_to_model(req) + return self._requestModel.get_model_invoice(key) @pyqtSlot('QString') def delete_invoice(self, key: str): @@ -367,5 +365,4 @@ class QEWallet(QObject): @pyqtSlot('QString', result='QVariant') def get_invoice(self, key: str): - invoice = self.wallet.get_invoice(key) - return self._invoiceModel.invoice_to_model(invoice) + return self._invoiceModel.get_model_invoice(key) From a6e72ae42f075e174309b47ca8ac537843163402 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 10 May 2022 13:26:48 +0200 Subject: [PATCH 141/218] add initial Transaction Details page and backing qobject --- electrum/gui/qml/components/Constants.qml | 2 + electrum/gui/qml/components/History.qml | 8 + electrum/gui/qml/components/TxDetails.qml | 257 +++++++++++++++++++++ electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qetransactionlistmodel.py | 11 + electrum/gui/qml/qetxdetails.py | 143 ++++++++++++ 6 files changed, 423 insertions(+) create mode 100644 electrum/gui/qml/components/TxDetails.qml create mode 100644 electrum/gui/qml/qetxdetails.py diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index 01c0865e9..bd4d9e0ea 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -3,6 +3,7 @@ import QtQuick.Controls.Material 2.0 Item { readonly property int paddingTiny: 4 + readonly property int paddingXSmall: 6 readonly property int paddingSmall: 8 readonly property int paddingMedium: 12 readonly property int paddingLarge: 16 @@ -25,4 +26,5 @@ Item { property color colorCredit: "#ff80ff80" property color colorDebit: "#ffff8080" property color mutedForeground: 'gray' //Qt.lighter(Material.background, 2) + property color colorMine: "yellow" } diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index e94388919..84d584982 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -66,6 +66,14 @@ Pane { Layout.fillWidth: true Layout.preferredHeight: txinfo.height + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {'txid': model.txid}) + page.txDetailsChanged.connect(function() { + // update listmodel when details change + visualModel.model.update_tx_label(model.txid, page.label) + }) + } + GridLayout { id: txinfo columns: 3 diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml new file mode 100644 index 000000000..bef89c949 --- /dev/null +++ b/electrum/gui/qml/components/TxDetails.qml @@ -0,0 +1,257 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string title: qsTr("Transaction details") + + property string txid + + property alias label: txdetails.label + + signal txDetailsChanged + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Bump fee') + enabled: txdetails.canBump + //onTriggered: + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Cancel double-spend') + enabled: txdetails.canCancel + } + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip: true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Status') + color: Material.accentColor + } + + Label { + text: txdetails.status + } + + Label { + text: qsTr('Mempool depth') + color: Material.accentColor + visible: !txdetails.isMined + } + + Label { + text: txdetails.mempoolDepth + visible: !txdetails.isMined + } + + Label { + text: qsTr('Date') + color: Material.accentColor + } + + Label { + text: txdetails.date + } + + Label { + text: txdetails.amount.satsInt > 0 + ? qsTr('Amount received') + : qsTr('Amount sent') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatSats(txdetails.amount) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Transaction fee') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatSats(txdetails.fee) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Transaction ID') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: root.txid + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Transaction ID'), 'text': root.txid }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Label') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + id: labelContent + + property bool editmode: false + + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + visible: !labelContent.editmode + text: txdetails.label + wrapMode: Text.Wrap + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + } + ToolButton { + visible: !labelContent.editmode + icon.source: '../../icons/pen.png' + icon.color: 'transparent' + onClicked: { + labelEdit.text = txdetails.label + labelContent.editmode = true + } + } + TextField { + id: labelEdit + visible: labelContent.editmode + text: txdetails.label + font.pixelSize: constants.fontSizeLarge + Layout.fillWidth: true + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/confirmed.png' + icon.color: 'transparent' + onClicked: { + labelContent.editmode = false + txdetails.set_label(labelEdit.text) + } + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/delete.png' + icon.color: 'transparent' + onClicked: labelContent.editmode = false + } + } + } + + + Label { + text: qsTr('Outputs') + Layout.columnSpan: 2 + color: Material.accentColor + } + + Repeater { + model: txdetails.outputs + delegate: TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + RowLayout { + width: parent.width + Label { + text: modelData.address + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + color: modelData.is_mine ? constants.colorMine : Material.foreground + } + Label { + text: Config.formatSats(modelData.value) + font.pixelSize: constants.fontSizeLarge + } + Label { + text: Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } + } + } + } + + } + } + + TxDetails { + id: txdetails + wallet: Daemon.currentWallet + txid: root.txid + onLabelChanged: txDetailsChanged() + } + + Component { + id: share + GenericShareDialog {} + } + +} diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 146ac9307..d41314f4c 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -22,6 +22,7 @@ from .qetxfinalizer import QETxFinalizer from .qeinvoice import QEInvoice from .qetypes import QEAmount from .qeaddressdetails import QEAddressDetails +from .qetxdetails import QETxDetails notification = None @@ -120,6 +121,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') + qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 3426b0488..71d474afd 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -117,6 +117,17 @@ class QETransactionListModel(QAbstractListModel): return i = i + 1 + @pyqtSlot(str, str) + def update_tx_label(self, txid, label): + i = 0 + for tx in self.tx_history: + if tx['txid'] == txid: + tx['label'] = label + index = self.index(i,0) + self.dataChanged.emit(index, index, [self._ROLE_RMAP['label']]) + return + i = i + 1 + @pyqtSlot(int) def updateBlockchainHeight(self, height): self._logger.debug('updating height to %d' % height) diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py new file mode 100644 index 000000000..e8e427ce6 --- /dev/null +++ b/electrum/gui/qml/qetxdetails.py @@ -0,0 +1,143 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +#from decimal import Decimal + +from electrum.logging import get_logger +from electrum.util import format_time + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QETxDetails(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _txid = None + + _mempool_depth = None + _date = None + + detailsChanged = pyqtSignal() + + 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.walletChanged.emit() + + txidChanged = pyqtSignal() + @pyqtProperty(str, notify=txidChanged) + def txid(self): + return self._txid + + @txid.setter + def txid(self, txid: str): + if self._txid != txid: + self._logger.debug('txid set -> %s' % txid) + self._txid = txid + self.txidChanged.emit() + self.update() + + labelChanged = pyqtSignal() + @pyqtProperty(str, notify=labelChanged) + def label(self): + return self._label + + @pyqtSlot(str) + def set_label(self, label: str): + if label != self._label: + self._wallet.wallet.set_label(self._txid, label) + self._label = label + self.labelChanged.emit() + + @pyqtProperty(str, notify=detailsChanged) + def status(self): + return self._status + + @pyqtProperty(str, notify=detailsChanged) + def date(self): + return self._date + + @pyqtProperty(str, notify=detailsChanged) + def mempoolDepth(self): + return self._mempool_depth + + @pyqtProperty(bool, notify=detailsChanged) + def isMined(self): + return self._is_mined + + @pyqtProperty(bool, notify=detailsChanged) + def isLightningFundingTx(self): + return self._is_lightning_funding_tx + + @pyqtProperty(bool, notify=detailsChanged) + def canBump(self): + return self._can_bump + + @pyqtProperty(bool, notify=detailsChanged) + def canCancel(self): + return self._can_dscancel + + @pyqtProperty(QEAmount, notify=detailsChanged) + def amount(self): + return self._amount + + @pyqtProperty(QEAmount, notify=detailsChanged) + def fee(self): + return self._fee + + @pyqtProperty('QVariantList', notify=detailsChanged) + def inputs(self): + return self._inputs + + @pyqtProperty('QVariantList', notify=detailsChanged) + def outputs(self): + return self._outputs + + def update(self): + if self._wallet is None: + self._logger.error('wallet undefined') + return + + # abusing get_input_tx to get tx from txid + tx = self._wallet.wallet.get_input_tx(self._txid) + + self._inputs = list(map(lambda x: x.to_json(), tx.inputs())) + self._outputs = list(map(lambda x: { + 'address': x.get_ui_address_str(), + 'value': QEAmount(amount_sat=x.value), + 'is_mine': self._wallet.wallet.is_mine(x.get_ui_address_str()) + }, tx.outputs())) + + txinfo = self._wallet.wallet.get_tx_info(tx) + self._status = txinfo.status + self._label = txinfo.label + self._amount = QEAmount(amount_sat=txinfo.amount) # can be None? + self._fee = QEAmount(amount_sat=txinfo.fee) + + self._is_mined = txinfo.tx_mined_status != None + if self._is_mined: + self._date = format_time(txinfo.tx_mined_status.timestamp) + else: + #TODO mempool_depth_bytes can be None? + self._mempool_depth = self._wallet.wallet.config.depth_tooltip(txinfo.mempool_depth_bytes) + + self._is_lightning_funding_tx = txinfo.is_lightning_funding_tx + self._can_bump = txinfo.can_bump + self._can_dscancel = txinfo.can_dscancel + + self._logger.debug(repr(txinfo.mempool_depth_bytes)) + self._logger.debug(repr(txinfo.can_broadcast)) + self._logger.debug(repr(txinfo.can_cpfp)) + self._logger.debug(repr(txinfo.can_save_as_local)) + self._logger.debug(repr(txinfo.can_remove)) + + self.detailsChanged.emit() From bbc1f4dba884d57e1094b87864994d298061123c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 10 May 2022 14:29:43 +0200 Subject: [PATCH 142/218] enable android share option --- .../gui/qml/components/AddressDetails.qml | 3 +++ electrum/gui/qml/components/RequestDialog.qml | 6 +++++- .../controls/GenericShareDialog.qml | 6 ++++-- electrum/gui/qml/qeapp.py | 21 +++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/AddressDetails.qml b/electrum/gui/qml/components/AddressDetails.qml index 51e008c82..8bb606452 100644 --- a/electrum/gui/qml/components/AddressDetails.qml +++ b/electrum/gui/qml/components/AddressDetails.qml @@ -26,6 +26,7 @@ Pane { text: qsTr('Spend from') //onTriggered: icon.source: '../../icons/tab_send.png' + enabled: false } } MenuItem { @@ -33,6 +34,7 @@ Pane { action: Action { text: qsTr('Sign/Verify') icon.source: '../../icons/key.png' + enabled: false } } MenuItem { @@ -40,6 +42,7 @@ Pane { action: Action { text: qsTr('Encrypt/Decrypt') icon.source: '../../icons/mail_icon.png' + enabled: false } } } diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 0d5bd9e48..e0aa4af40 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -113,7 +113,11 @@ Dialog { Button { icon.source: '../../icons/share.png' text: 'Share' - enabled: false + onClicked: { + enabled = false + AppController.doShare(_bip21uri, qsTr('Payment Request')) + enabled = true + } } } Label { diff --git a/electrum/gui/qml/components/controls/GenericShareDialog.qml b/electrum/gui/qml/components/controls/GenericShareDialog.qml index 9ef834b74..392ebfcd6 100644 --- a/electrum/gui/qml/components/controls/GenericShareDialog.qml +++ b/electrum/gui/qml/components/controls/GenericShareDialog.qml @@ -96,10 +96,12 @@ Dialog { onClicked: AppController.textToClipboard(dialog.text) } Button { - enabled: false + //enabled: false text: qsTr('Share') icon.source: '../../../icons/share.png' - onClicked: console.log('TODO') + onClicked: { + AppController.doShare(dialog.text, dialog.title) + } } } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index d41314f4c..5d20a305f 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -93,6 +93,27 @@ class QEAppController(QObject): except ImportError: self.logger.error('Notification: needs plyer; `sudo python3 -m pip install plyer`') + @pyqtSlot(str, str) + def doShare(self, data, title): + #if platform != 'android': + #return + try: + from jnius import autoclass, cast + except ImportError: + self.logger.error('Share: needs jnius. Platform not Android?') + return + + JS = autoclass('java.lang.String') + Intent = autoclass('android.content.Intent') + sendIntent = Intent() + sendIntent.setAction(Intent.ACTION_SEND) + sendIntent.setType("text/plain") + sendIntent.putExtra(Intent.EXTRA_TEXT, JS(data)) + pythonActivity = autoclass('org.kivy.android.PythonActivity') + currentActivity = cast('android.app.Activity', pythonActivity.mActivity) + it = Intent.createChooser(sendIntent, cast('java.lang.CharSequence', JS(title))) + currentActivity.startActivity(it) + @pyqtSlot('QString') def textToClipboard(self, text): QGuiApplication.clipboard().setText(text) From 98c03ec991e7cdd7b5eb2f9073e8a15b28add915 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 10 May 2022 17:11:16 +0200 Subject: [PATCH 143/218] about page --- electrum/gui/qml/components/About.qml | 90 +++++++++++++++++++ .../gui/qml/components/WalletMainView.qml | 9 ++ 2 files changed, 99 insertions(+) create mode 100644 electrum/gui/qml/components/About.qml diff --git a/electrum/gui/qml/components/About.qml b/electrum/gui/qml/components/About.qml new file mode 100644 index 000000000..b66453432 --- /dev/null +++ b/electrum/gui/qml/components/About.qml @@ -0,0 +1,90 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +Pane { + property string title: qsTr("About Electrum") + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + interactive: height < contentHeight + + GridLayout { + id: rootLayout + columns: 2 + width: parent.width + + Item { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: parent.width + Layout.preferredHeight: parent.width * 3/4 // reduce height, empty space in png + + Image { + id: electrum_logo + width: parent.width + height: width + source: '../../icons/electrum_presplash.png' + } + } + + Label { + text: qsTr('Version') + Layout.alignment: Qt.AlignRight + } + Label { + text: BUILD.electrum_version + } + Label { + text: qsTr('APK Version') + Layout.alignment: Qt.AlignRight + } + Label { + text: BUILD.apk_version + } + Label { + text: qsTr('Protocol version') + Layout.alignment: Qt.AlignRight + } + Label { + text: BUILD.protocol_version + } + Label { + text: qsTr('License') + Layout.alignment: Qt.AlignRight + } + Label { + text: qsTr('MIT License') + } + Label { + text: qsTr('Homepage') + Layout.alignment: Qt.AlignRight + } + Label { + text: qsTr('https://electrum.org') + textFormat: Text.RichText + onLinkActivated: Qt.openUrlExternally(link) + } + Label { + text: qsTr('Developers') + Layout.alignment: Qt.AlignRight + } + Label { + text: 'Thomas Voegtlin\nSomberNight\nSander van Grieken' + } + Item { + width: 1 + height: constants.paddingXLarge + Layout.columnSpan: 2 + } + Label { + text: qsTr('Distributed by Electrum Technologies GmbH') + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + } + } + } + +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index f41cb7055..4ff3600f8 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -44,6 +44,15 @@ Item { } } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('About'); + onTriggered: menu.openPage(Qt.resolvedUrl('About.qml')) + icon.source: '../../icons/electrum.png' + } + } + function openPage(url) { stack.push(url) currentIndex = -1 From 532d19979d71e4655d1999519345f4fe670796f2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 10 May 2022 19:31:25 +0200 Subject: [PATCH 144/218] expose additional wallet properties (lightning enabled, balance, masterpubkey) and a few smaller improvements --- .../gui/qml/components/AddressDetails.qml | 1 + electrum/gui/qml/components/TxDetails.qml | 1 + electrum/gui/qml/components/Wallets.qml | 168 ++++++++++++------ .../components/controls/TextHighlightPane.qml | 1 + electrum/gui/qml/qewallet.py | 20 +++ 5 files changed, 137 insertions(+), 54 deletions(-) diff --git a/electrum/gui/qml/components/AddressDetails.qml b/electrum/gui/qml/components/AddressDetails.qml index 8bb606452..79930fe41 100644 --- a/electrum/gui/qml/components/AddressDetails.qml +++ b/electrum/gui/qml/components/AddressDetails.qml @@ -122,6 +122,7 @@ Pane { onClicked: { labelEdit.text = addressdetails.label labelContent.editmode = true + labelEdit.focus = true } } TextField { diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml index bef89c949..e1820a3a6 100644 --- a/electrum/gui/qml/components/TxDetails.qml +++ b/electrum/gui/qml/components/TxDetails.qml @@ -175,6 +175,7 @@ Pane { onClicked: { labelEdit.text = txdetails.label labelContent.editmode = true + labelEdit.focus = true } } TextField { diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 1e6156a32..179a23df9 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -17,39 +17,71 @@ Pane { width: parent.width height: parent.height - Item { - width: parent.width - height: detailsLayout.height + GridLayout { + id: detailsLayout + Layout.preferredWidth: parent.width + columns: 4 - GridLayout { - id: detailsLayout - width: parent.width - columns: 4 + Label { text: 'Wallet'; Layout.columnSpan: 2; color: Material.accentColor } + Label { text: Daemon.currentWallet.name; Layout.columnSpan: 2 } + + Label { text: 'derivation prefix (BIP32)'; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } + Label { text: Daemon.currentWallet.derivationPrefix; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } + + Label { text: 'txinType'; color: Material.accentColor } + Label { text: Daemon.currentWallet.txinType } - Label { text: 'Wallet'; Layout.columnSpan: 2 } - Label { text: Daemon.currentWallet.name; Layout.columnSpan: 2; color: Material.accentColor } + Label { text: 'is deterministic'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isDeterministic } - Label { text: 'derivation prefix (BIP32)'; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } - Label { text: Daemon.currentWallet.derivationPrefix; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } + Label { text: 'is watch only'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isWatchOnly } - Label { text: 'txinType' } - Label { text: Daemon.currentWallet.txinType; color: Material.accentColor } + Label { text: 'is Encrypted'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isEncrypted } - Label { text: 'is deterministic' } - Label { text: Daemon.currentWallet.isDeterministic; color: Material.accentColor } + Label { text: 'is Hardware'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isHardware } - Label { text: 'is watch only' } - Label { text: Daemon.currentWallet.isWatchOnly; color: Material.accentColor } + Label { text: 'is Lightning'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isLightning } - Label { text: 'is Encrypted' } - Label { text: Daemon.currentWallet.isEncrypted; color: Material.accentColor } + Label { text: 'has Seed'; color: Material.accentColor } + Label { text: Daemon.currentWallet.hasSeed; Layout.columnSpan: 3 } - Label { text: 'is Hardware' } - Label { text: Daemon.currentWallet.isHardware; color: Material.accentColor } + Label { Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor } + + TextHighlightPane { + Layout.columnSpan: 4 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: Daemon.currentWallet.masterPubkey + wrapMode: Text.Wrap + Layout.fillWidth: true + font.pixelSize: constants.fontSizeMedium + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(rootItem, { + 'title': qsTr('Master Public Key'), + 'text': Daemon.currentWallet.masterPubkey + }) + dialog.open() + } + } } } -// } + } + + Item { width: 1; height: 1 } Frame { id: detailsFrame @@ -59,49 +91,71 @@ Pane { horizontalPadding: 0 background: PaneInsetBackground {} - ListView { - id: listview - width: parent.width - height: parent.height - clip: true - model: Daemon.availableWallets - - delegate: AbstractButton { - width: ListView.view.width - height: row.height + 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: row - spacing: 10 - x: constants.paddingSmall - width: parent.width - 2 * constants.paddingSmall - - Image { - id: walleticon - source: "../../icons/wallet.png" - fillMode: Image.PreserveAspectFit - Layout.preferredWidth: constants.iconSizeLarge - Layout.preferredHeight: constants.iconSizeLarge - } - + id: hitem + width: parent.width Label { + text: qsTr('Available wallets') font.pixelSize: constants.fontSizeLarge - text: model.name - Layout.fillWidth: true + color: Material.accentColor } + } + } - Button { - text: 'Open' - onClicked: { - Daemon.load_wallet(model.path) + ListView { + id: listview + Layout.preferredWidth: parent.width + Layout.fillHeight: true + clip: true + model: Daemon.availableWallets + + delegate: AbstractButton { + width: ListView.view.width + height: row.height + + RowLayout { + id: row + spacing: 10 + x: constants.paddingSmall + width: parent.width - 2 * constants.paddingSmall + + Image { + id: walleticon + source: "../../icons/wallet.png" + fillMode: Image.PreserveAspectFit + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + } + + Label { + font.pixelSize: constants.fontSizeLarge + text: model.name + Layout.fillWidth: true + } + + Button { + text: 'Open' + onClicked: { + Daemon.load_wallet(model.path) + } } } } - } - ScrollIndicator.vertical: ScrollIndicator { } + ScrollIndicator.vertical: ScrollIndicator { } + } } - } Button { @@ -126,4 +180,10 @@ Pane { } } + Component { + id: share + GenericShareDialog { + onClosed: destroy() + } + } } diff --git a/electrum/gui/qml/components/controls/TextHighlightPane.qml b/electrum/gui/qml/components/controls/TextHighlightPane.qml index 9920d28c7..5ac2e5331 100644 --- a/electrum/gui/qml/components/controls/TextHighlightPane.qml +++ b/electrum/gui/qml/components/controls/TextHighlightPane.qml @@ -6,5 +6,6 @@ import QtQuick.Controls.Material 2.0 Pane { background: Rectangle { color: Qt.lighter(Material.background, 1.15) + radius: constants.paddingSmall } } diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index a1f2cf3a9..2b398085f 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -197,6 +197,15 @@ class QEWallet(QObject): def name(self): return self.wallet.basename() + isLightningChanged = pyqtSignal() + @pyqtProperty(bool, notify=isLightningChanged) + def isLightning(self): + return bool(self.wallet.lnworker) + + @pyqtProperty(bool, notify=dataChanged) + def hasSeed(self): + return self.wallet.has_seed() + @pyqtProperty('QString', notify=dataChanged) def txinType(self): return self.wallet.get_txin_type(self.wallet.dummy_address()) @@ -224,6 +233,10 @@ class QEWallet(QObject): self._logger.debug('multiple keystores not supported yet') return keystores[0].get_derivation_prefix() + @pyqtProperty(str, notify=dataChanged) + def masterPubkey(self): + return self.wallet.get_master_public_key() + balanceChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=balanceChanged) @@ -243,6 +256,13 @@ class QEWallet(QObject): self._confirmedbalance = QEAmount(amount_sat=c+x) return self._confirmedbalance + @pyqtProperty(QEAmount, notify=balanceChanged) + def lightningBalance(self): + if not self.isLightning: + return QEAmount() + self._lightningbalance = QEAmount(amount_sat=self.wallet.lnworker.get_balance()) + return self._lightningbalance + @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): self._logger.info('send_onchain: %s %d' % (address,amount)) From 5e92624f33f670d3aca1a9f1006efb4f84efcc5e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 11 May 2022 12:08:29 +0200 Subject: [PATCH 145/218] refactor btc <-> fiat amount behaviour into separate controls --- electrum/gui/qml/components/Receive.qml | 31 +++---------------- electrum/gui/qml/components/Send.qml | 30 +++--------------- .../gui/qml/components/controls/BtcField.qml | 28 +++++++++++++++++ .../gui/qml/components/controls/FiatField.qml | 18 +++++++++++ 4 files changed, 54 insertions(+), 53 deletions(-) create mode 100644 electrum/gui/qml/components/controls/BtcField.qml create mode 100644 electrum/gui/qml/components/controls/FiatField.qml diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index b6648001e..24c11d6db 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -36,27 +36,10 @@ Pane { Layout.rightMargin: constants.paddingXLarge } - TextField { + BtcField { id: amount - font.family: FixedFont + fiatfield: amountFiat Layout.preferredWidth: parent.width /2 - placeholderText: qsTr('Amount') - inputMethodHints: Qt.ImhPreferNumbers - - property Amount textAsSats - onTextChanged: { - textAsSats = Config.unitsToSats(amount.text) - if (amountFiat.activeFocus) - return - amountFiat.text = text == '' ? '' : Daemon.fx.fiatValue(amount.textAsSats) - } - - Connections { - target: Config - function onBaseUnitChanged() { - amount.text = amount.textAsSats != 0 ? Config.satsToUnits(amount.textAsSats) : '' - } - } } Label { @@ -68,17 +51,11 @@ Pane { Item { visible: Daemon.fx.enabled; width: 1; height: 1 } - TextField { + FiatField { id: amountFiat + btcfield: amount visible: Daemon.fx.enabled - font.family: FixedFont Layout.preferredWidth: parent.width /2 - placeholderText: qsTr('Amount') - inputMethodHints: Qt.ImhDigitsOnly - onTextChanged: { - if (amountFiat.activeFocus) - amount.text = text == '' ? '' : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) - } } Label { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 55de55518..d4c6ca75f 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -71,26 +71,10 @@ Pane { text: qsTr('Amount') } - TextField { + BtcField { id: amount - font.family: FixedFont - placeholderText: qsTr('Amount') + fiatfield: amountFiat Layout.preferredWidth: parent.width /2 - inputMethodHints: Qt.ImhPreferNumbers - property Amount textAsSats - onTextChanged: { - textAsSats = Config.unitsToSats(amount.text) - if (amountFiat.activeFocus) - return - amountFiat.text = Daemon.fx.fiatValue(amount.textAsSats) - } - - Connections { - target: Config - function onBaseUnitChanged() { - amount.text = amount.textAsSats != 0 ? Config.satsToUnits(amount.textAsSats) : '' - } - } } Label { @@ -103,17 +87,11 @@ Pane { Item { width: 1; height: 1; visible: Daemon.fx.enabled } - TextField { + FiatField { id: amountFiat + btcfield: amount visible: Daemon.fx.enabled - font.family: FixedFont Layout.preferredWidth: parent.width /2 - placeholderText: qsTr('Amount') - inputMethodHints: Qt.ImhPreferNumbers - onTextChanged: { - if (amountFiat.activeFocus) - amount.text = text == '' ? '' : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) - } } Label { diff --git a/electrum/gui/qml/components/controls/BtcField.qml b/electrum/gui/qml/components/controls/BtcField.qml new file mode 100644 index 000000000..552c5fc77 --- /dev/null +++ b/electrum/gui/qml/components/controls/BtcField.qml @@ -0,0 +1,28 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 + +import org.electrum 1.0 + +TextField { + id: amount + + required property TextField fiatfield + + font.family: FixedFont + placeholderText: qsTr('Amount') + inputMethodHints: Qt.ImhPreferNumbers + property Amount textAsSats + onTextChanged: { + textAsSats = Config.unitsToSats(amount.text) + if (fiatfield.activeFocus) + return + fiatfield.text = text == '' ? '' : Daemon.fx.fiatValue(amount.textAsSats) + } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = amount.textAsSats != 0 ? Config.satsToUnits(amount.textAsSats) : '' + } + } +} diff --git a/electrum/gui/qml/components/controls/FiatField.qml b/electrum/gui/qml/components/controls/FiatField.qml new file mode 100644 index 000000000..cff9a7cad --- /dev/null +++ b/electrum/gui/qml/components/controls/FiatField.qml @@ -0,0 +1,18 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 + +import org.electrum 1.0 + +TextField { + id: amountFiat + + required property TextField btcfield + + font.family: FixedFont + placeholderText: qsTr('Amount') + inputMethodHints: Qt.ImhPreferNumbers + onTextChanged: { + if (amountFiat.activeFocus) + btcfield.text = text == '' ? '' : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) + } +} From e84bc4561f05646fc5f704390ad19ad1121b6ab8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 11 May 2022 12:22:31 +0200 Subject: [PATCH 146/218] bold font wasn't loaded when regular font loading was succesful some styling fixes --- electrum/gui/qml/components/Addresses.qml | 2 +- electrum/gui/qml/components/Receive.qml | 3 ++- electrum/gui/qml/components/Send.qml | 3 ++- electrum/gui/qml/qeapp.py | 9 +++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index c58d237a0..c4d249da4 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -81,7 +81,7 @@ Pane { color: model.held ? Qt.rgba(1,0,0,0.75) : model.numtx > 0 - ? model.balance == 0 + ? model.balance.satsInt == 0 ? Qt.rgba(0.5,0.5,0.5,1) : Qt.rgba(0.75,0.75,0.75,1) : model.type == 'receive' diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 24c11d6db..f53b865dd 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -143,7 +143,8 @@ Pane { width: parent.width Label { text: qsTr('Receive queue') - font.pixelSize: constants.fontSizeXLarge + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor } } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index d4c6ca75f..1fbcdc458 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -176,7 +176,8 @@ Pane { width: parent.width Label { text: qsTr('Send queue') - font.pixelSize: constants.fontSizeXLarge + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor } } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 5d20a305f..36dfc99d6 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -154,10 +154,11 @@ class ElectrumQmlApplication(QGuiApplication): # add a monospace font as we can't rely on device having one self.fixedFont = 'PT Mono' - if (QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Regular.ttf') < 0 and - QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Bold.ttf') < 0): - self.logger.warning('Could not load font PT Mono') - self.fixedFont = 'Monospace' # hope for the best + not_loaded = QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Regular.ttf') < 0 + not_loaded = QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Bold.ttf') < 0 and not_loaded + if not_loaded: + self.logger.warning('Could not load font PT Mono') + self.fixedFont = 'Monospace' # hope for the best self.context = self.engine.rootContext() self._qeconfig = QEConfig(config) From 69eb0f3f475187fb4937cd4f53cc016d5ea0dbcd Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 12 May 2022 10:22:39 +0200 Subject: [PATCH 147/218] also move new-quotes event to FiatField --- electrum/gui/qml/components/Receive.qml | 9 --------- electrum/gui/qml/components/Send.qml | 9 --------- electrum/gui/qml/components/controls/FiatField.qml | 10 ++++++++++ 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index f53b865dd..92507eccb 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -230,13 +230,4 @@ Pane { } } - Connections { - target: Daemon.fx - function onQuotesUpdated() { - amountFiat.text = amount.text == '' - ? '' - : Daemon.fx.fiatValue(Config.unitsToSats(amount.text)) - } - } - } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 1fbcdc458..06d614617 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -244,15 +244,6 @@ Pane { } } - Connections { - target: Daemon.fx - function onQuotesUpdated() { - amountFiat.text = amount.text == '' - ? '' - : Daemon.fx.fiatValue(Config.unitsToSats(amount.text)) - } - } - // make clicking the dialog background move the scope away from textedit fields // so the keyboard goes away MouseArea { diff --git a/electrum/gui/qml/components/controls/FiatField.qml b/electrum/gui/qml/components/controls/FiatField.qml index cff9a7cad..59cb0f180 100644 --- a/electrum/gui/qml/components/controls/FiatField.qml +++ b/electrum/gui/qml/components/controls/FiatField.qml @@ -15,4 +15,14 @@ TextField { if (amountFiat.activeFocus) btcfield.text = text == '' ? '' : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) } + + Connections { + target: Daemon.fx + function onQuotesUpdated() { + amountFiat.text = btcfield.text == '' + ? '' + : Daemon.fx.fiatValue(Config.unitsToSats(btcfield.text)) + } + } + } From c55aa7bb4825440e19c80afe82c66c1a1058447f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 12 May 2022 16:53:44 +0200 Subject: [PATCH 148/218] wip lightning --- electrum/gui/qml/components/Channels.qml | 132 +++++++++++++++ electrum/gui/qml/components/OpenChannel.qml | 145 ++++++++++++++++ electrum/gui/qml/components/Scan.qml | 15 -- .../gui/qml/components/WalletMainView.qml | 10 ++ electrum/gui/qml/qeapp.py | 4 + electrum/gui/qml/qechannellistmodel.py | 53 ++++++ electrum/gui/qml/qechannelopener.py | 160 ++++++++++++++++++ electrum/gui/qml/qewallet.py | 9 + 8 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 electrum/gui/qml/components/Channels.qml create mode 100644 electrum/gui/qml/components/OpenChannel.qml create mode 100644 electrum/gui/qml/qechannellistmodel.py create mode 100644 electrum/gui/qml/qechannelopener.py diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml new file mode 100644 index 000000000..1939c6736 --- /dev/null +++ b/electrum/gui/qml/components/Channels.qml @@ -0,0 +1,132 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + property string title: qsTr("Lightning Channels") + + ColumnLayout { + id: layout + width: parent.width + height: parent.height + + GridLayout { + id: summaryLayout + Layout.preferredWidth: parent.width + columns: 2 + + Label { + Layout.columnSpan: 2 + text: '' + } + + Label { + text: qsTr('You can send:') + color: Material.accentColor + } + + Label { + text: '' + } + + Label { + text: qsTr('You can receive:') + color: Material.accentColor + } + + Label { + text: '' + } + + RowLayout { + Layout.columnSpan: 2 + + Button { + text: qsTr('Open Channel') + onClicked: app.stack.push(Qt.resolvedUrl('OpenChannel.qml')) + } + } + } + + + Frame { + id: channelsFrame + Layout.preferredWidth: parent.width + Layout.fillHeight: true + verticalPadding: 0 + horizontalPadding: 0 + 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('Channels') + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + } + } + } + + ListView { + id: listview + Layout.preferredWidth: parent.width + Layout.fillHeight: true + clip: true + model: 3 //Daemon.currentWallet.channelsModel + + delegate: ItemDelegate { + width: ListView.view.width + height: row.height + highlighted: ListView.isCurrentItem + + font.pixelSize: constants.fontSizeMedium // set default font size for child controls + + RowLayout { + id: row + spacing: 10 + x: constants.paddingSmall + width: parent.width - 2 * constants.paddingSmall + + Image { + id: walleticon + source: "../../icons/lightning.png" + fillMode: Image.PreserveAspectFit + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + } + + Label { + font.pixelSize: constants.fontSizeLarge + text: index + Layout.fillWidth: true + } + + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + + } + + Component.onCompleted: Daemon.currentWallet.channelModel.init_model() +} diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml new file mode 100644 index 000000000..369b0da11 --- /dev/null +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -0,0 +1,145 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + + property string title: qsTr("Open Lightning Channel") + + GridLayout { + id: form + width: parent.width + rowSpacing: constants.paddingSmall + columnSpacing: constants.paddingSmall + columns: 4 + + Label { + text: qsTr('Node') + } + + TextArea { + id: node + Layout.columnSpan: 2 + Layout.fillWidth: true + font.family: FixedFont + wrapMode: Text.Wrap + placeholderText: qsTr('Paste or scan node uri/pubkey') + onActiveFocusChanged: { + if (!activeFocus) + channelopener.nodeid = text + } + } + + RowLayout { + spacing: 0 + ToolButton { + icon.source: '../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: { + channelopener.nodeid = AppController.clipboardToText() + node.text = channelopener.nodeid + } + } + 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() { + channelopener.nodeid = page.scanData + node.text = channelopener.nodeid + }) + } + } + } + + Label { + text: qsTr('Amount') + } + + BtcField { + id: amount + fiatfield: amountFiat + Layout.preferredWidth: parent.width /2 + onTextChanged: channelopener.amount = Config.unitsToSats(amount.text) + enabled: !is_max.checked + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + Label { + text: Config.baseUnit + color: Material.accentColor + } + Switch { + id: is_max + text: qsTr('Max') + onCheckedChanged: { + if (checked) { + channelopener.amount = MAX + } + } + } + } + + Item { width: 1; height: 1; visible: Daemon.fx.enabled } + + FiatField { + id: amountFiat + btcfield: amount + visible: Daemon.fx.enabled + Layout.preferredWidth: parent.width /2 + enabled: !is_max.checked + } + + Label { + visible: Daemon.fx.enabled + text: Daemon.fx.fiatCurrency + color: Material.accentColor + Layout.fillWidth: true + } + + Item { visible: Daemon.fx.enabled ; height: 1; width: 1 } + + RowLayout { + Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter + + Button { + text: qsTr('Open Channel') + enabled: channelopener.valid + onClicked: channelopener.open_channel() + } + } + } + + + ChannelOpener { + id: channelopener + wallet: Daemon.currentWallet + onValidationError: { + if (code == 'invalid_nodeid') { + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + } + } + onConflictingBackup: { + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + dialog.yesClicked.connect(function() { + channelopener.open_channel(true) + }) + } + } + +} diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml index 22cb6443e..d0cb4ea23 100644 --- a/electrum/gui/qml/components/Scan.qml +++ b/electrum/gui/qml/components/Scan.qml @@ -12,7 +12,6 @@ Item { property bool toolbar: false property string scanData - property var invoiceData: undefined property string error signal found @@ -24,16 +23,6 @@ Item { onFound: { scanPage.scanData = scanData - var invoice = bitcoin.parse_uri(scanData) - if (invoice['error']) { - error = invoice['error'] - console.log(error) - app.stack.pop() - return - } - - invoiceData = invoice - console.log(invoiceData['address']) scanPage.found() app.stack.pop() } @@ -46,8 +35,4 @@ Item { text: 'Cancel' onClicked: app.stack.pop() } - - Bitcoin { - id: bitcoin - } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 4ff3600f8..110a49509 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -35,6 +35,16 @@ Item { icon.source: '../../icons/network.png' } } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Channels'); + enabled: Daemon.currentWallet.isLightning + onTriggered: menu.openPage(Qt.resolvedUrl('Channels.qml')) + icon.source: '../../icons/lightning.png' + } + } + MenuItem { icon.color: 'transparent' action: Action { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 36dfc99d6..9218de865 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -23,6 +23,7 @@ from .qeinvoice import QEInvoice from .qetypes import QEAmount from .qeaddressdetails import QEAddressDetails from .qetxdetails import QETxDetails +from .qechannelopener import QEChannelOpener notification = None @@ -143,6 +144,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') + qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') @@ -165,11 +167,13 @@ class ElectrumQmlApplication(QGuiApplication): self._qenetwork = QENetwork(daemon.network) self._qedaemon = QEDaemon(daemon) self._appController = QEAppController(self._qedaemon) + self._maxAmount = QEAmount(is_max=True) self.context.setContextProperty('AppController', self._appController) self.context.setContextProperty('Config', self._qeconfig) self.context.setContextProperty('Network', self._qenetwork) self.context.setContextProperty('Daemon', self._qedaemon) self.context.setContextProperty('FixedFont', self.fixedFont) + self.context.setContextProperty('MAX', self._maxAmount) self.context.setContextProperty('BUILD', { 'electrum_version': version.ELECTRUM_VERSION, 'apk_version': version.APK_VERSION, diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py new file mode 100644 index 000000000..531ca0203 --- /dev/null +++ b/electrum/gui/qml/qechannellistmodel.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta + +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, TxMinedInfo + +from .qetypes import QEAmount + +class QEChannelListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.channels = [] + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('cid','state','initiator','capacity','can_send','can_receive', + 'l_csv_delat','r_csv_delay','send_frozen','receive_frozen', + 'type','node_id','funding_tx') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) + + def rowCount(self, index): + return len(self.tx_history) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + tx = self.tx_history[index.row()] + role_index = role - Qt.UserRole + value = tx[self._ROLE_NAMES[role_index]] + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: + return value + if isinstance(value, Satoshis): + return value.value + if isinstance(value, QEAmount): + return value + return str(value) + + @pyqtSlot() + def init_model(self): + if not self.wallet.lnworker: + self._logger.warning('lnworker should be defined') + return + + channels = self.wallet.lnworker.channels + self._logger.debug(repr(channels)) + #channels = list(lnworker.channels.values()) if lnworker else [] diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py new file mode 100644 index 000000000..dde5c6c24 --- /dev/null +++ b/electrum/gui/qml/qechannelopener.py @@ -0,0 +1,160 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.util import format_time +from electrum.lnutil import extract_nodeid, ConnStringFormatError +from electrum.gui import messages + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QEChannelOpener(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _nodeid = None + _amount = QEAmount() + _valid = False + _opentx = None + + validationError = pyqtSignal([str,str], arguments=['code','message']) + conflictingBackup = pyqtSignal([str], arguments=['message']) + + 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.walletChanged.emit() + + nodeidChanged = pyqtSignal() + @pyqtProperty(str, notify=nodeidChanged) + def nodeid(self): + return self._nodeid + + @nodeid.setter + def nodeid(self, nodeid: str): + if self._nodeid != nodeid: + self._logger.debug('nodeid set -> %s' % nodeid) + self._nodeid = nodeid + self.nodeidChanged.emit() + self.validate() + + amountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=amountChanged) + def amount(self): + return self._amount + + @amount.setter + def amount(self, amount: QEAmount): + if self._amount != amount: + self._amount = amount + self.amountChanged.emit() + self.validate() + + validChanged = pyqtSignal() + @pyqtProperty(bool, notify=validChanged) + def valid(self): + return self._valid + + openTxChanged = pyqtSignal() + @pyqtProperty(bool, notify=openTxChanged) + def openTx(self): + return self._opentx + + def validate(self): + nodeid_valid = False + if self._nodeid: + try: + self._node_pubkey, self._host_port = extract_nodeid(self._nodeid) + nodeid_valid = True + except ConnStringFormatError as e: + self.validationError.emit('invalid_nodeid', repr(e)) + + if not nodeid_valid: + self._valid = False + self.validChanged.emit() + return + + self._logger.debug('amount=%s' % str(self._amount)) + if not self._amount or not (self._amount.satsInt > 0 or self._amount.isMax): + self._valid = False + self.validChanged.emit() + return + + self._valid = True + self.validChanged.emit() + + # FIXME "max" button in amount_dialog should enforce LN_MAX_FUNDING_SAT + @pyqtSlot() + @pyqtSlot(bool) + def open_channel(self, confirm_backup_conflict=False): + if not self.valid: + return + + #if self.use_gossip: + #conn_str = self.pubkey + #if self.ipport: + #conn_str += '@' + self.ipport.strip() + #else: + #conn_str = str(self.trampolines[self.pubkey]) + amount = '!' if self._amount.isMax else self._amount.satsInt + + lnworker = self._wallet.wallet.lnworker + if lnworker.has_conflicting_backup_with(node_pubkey) and not confirm_backup_conflict: + self.conflictingBackup.emit(messages.MGS_CONFLICTING_BACKUP_INSTANCE) + return + + coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True) + #node_id, rest = extract_nodeid(conn_str) + make_tx = lambda rbf: lnworker.mktx_for_open_channel( + coins=coins, + funding_sat=amount, + node_id=self._node_pubkey, + fee_est=None) + #on_pay = lambda tx: self.app.protected('Create a new channel?', self.do_open_channel, (tx, conn_str)) + #d = ConfirmTxDialog( + #self.app, + #amount = amount, + #make_tx=make_tx, + #on_pay=on_pay, + #show_final=False) + #d.open() + + #def do_open_channel(self, funding_tx, conn_str, password): + ## read funding_sat from tx; converts '!' to int value + #funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) + #lnworker = self.app.wallet.lnworker + #try: + #chan, funding_tx = lnworker.open_channel( + #connect_str=conn_str, + #funding_tx=funding_tx, + #funding_sat=funding_sat, + #push_amt_sat=0, + #password=password) + #except Exception as e: + #self.app.logger.exception("Problem opening channel") + #self.app.show_error(_('Problem opening channel: ') + '\n' + repr(e)) + #return + ## TODO: it would be nice to show this before broadcasting + #if chan.has_onchain_backup(): + #self.maybe_show_funding_tx(chan, funding_tx) + #else: + #title = _('Save backup') + #help_text = _(messages.MSG_CREATED_NON_RECOVERABLE_CHANNEL) + #data = lnworker.export_channel_backup(chan.channel_id) + #popup = QRDialog( + #title, data, + #show_text=False, + #text_for_clipboard=data, + #help_text=help_text, + #close_button_text=_('OK'), + #on_close=lambda: self.maybe_show_funding_tx(chan, funding_tx)) + #popup.open() diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 2b398085f..08c9bc8b7 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -17,6 +17,7 @@ from electrum.invoices import (Invoice, InvoiceError, from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel +from .qechannellistmodel import QEChannelListModel from .qetypes import QEAmount class QEWallet(QObject): @@ -60,6 +61,7 @@ class QEWallet(QObject): self._addressModel = QEAddressListModel(wallet) self._requestModel = QERequestListModel(wallet) self._invoiceModel = QEInvoiceListModel(wallet) + self._channelModel = None self._historyModel.init_model() self._requestModel.init_model() @@ -192,6 +194,13 @@ class QEWallet(QObject): def invoiceModel(self): return self._invoiceModel + channelModelChanged = pyqtSignal() + @pyqtProperty(QEChannelListModel, notify=channelModelChanged) + def channelModel(self): + if self._channelModel is None: + self._channelModel = QEChannelListModel(self.wallet) + return self._channelModel + nameChanged = pyqtSignal() @pyqtProperty('QString', notify=nameChanged) def name(self): From 8807a428ed5888b2cc03eab5054e2adce1495e89 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Jun 2022 13:06:59 +0200 Subject: [PATCH 149/218] rename ConfirmPaymentDialog to ConfirmTxDialog generalize/parameterize some labels and inject TxFinalizer instead of encapsulate --- ...mPaymentDialog.qml => ConfirmTxDialog.qml} | 22 +++++++++++-------- electrum/gui/qml/components/Send.qml | 8 ++++++- 2 files changed, 20 insertions(+), 10 deletions(-) rename electrum/gui/qml/components/{ConfirmPaymentDialog.qml => ConfirmTxDialog.qml} (91%) diff --git a/electrum/gui/qml/components/ConfirmPaymentDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml similarity index 91% rename from electrum/gui/qml/components/ConfirmPaymentDialog.qml rename to electrum/gui/qml/components/ConfirmTxDialog.qml index a0f04cd2b..3e2ef3161 100644 --- a/electrum/gui/qml/components/ConfirmPaymentDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -10,15 +10,22 @@ import "controls" Dialog { id: dialog - property alias address: finalizer.address - property alias satoshis: finalizer.amount + required property QtObject finalizer + required property Amount satoshis + property string address property string message + property alias amountLabelText: amountLabel.text + property alias sendButtonText: sendButton.text + + title: qsTr('Confirm Transaction') + + // copy these to finalizer + onAddressChanged: finalizer.address = address + onSatoshisChanged: finalizer.amount = satoshis width: parent.width height: parent.height - title: qsTr('Confirm Payment') - modal: true parent: Overlay.overlay Overlay.modal: Rectangle { @@ -39,6 +46,7 @@ Dialog { } Label { + id: amountLabel text: qsTr('Amount to send') } @@ -202,6 +210,7 @@ Dialog { } Button { + id: sendButton text: qsTr('Pay') enabled: finalizer.valid onClicked: { @@ -212,9 +221,4 @@ Dialog { } } - TxFinalizer { - id: finalizer - wallet: Daemon.currentWallet - onAmountChanged: console.log(amount.satsInt) - } } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 06d614617..495656a58 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -217,7 +217,13 @@ Pane { Component { id: confirmPaymentDialog - ConfirmPaymentDialog {} + ConfirmTxDialog { + title: qsTr('Confirm Payment') + finalizer: TxFinalizer { + wallet: Daemon.currentWallet + onAmountChanged: console.log(amount.satsInt) + } + } } Component { From 81b1f774e2be7900ee959da93d070fb7e6ab2150 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Jun 2022 13:10:58 +0200 Subject: [PATCH 150/218] expose more trampoline boilerplate allow delegation of make_tx and accept/send --- electrum/gui/qml/components/OpenChannel.qml | 19 ++++++++-- electrum/gui/qml/components/Preferences.qml | 17 +++++++++ electrum/gui/qml/qechannelopener.py | 39 ++++++++++++++------- electrum/gui/qml/qeconfig.py | 10 ++++++ electrum/gui/qml/qetxfinalizer.py | 13 ++++++- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml index 369b0da11..b317b4963 100644 --- a/electrum/gui/qml/components/OpenChannel.qml +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -23,8 +23,10 @@ Pane { text: qsTr('Node') } + // gossip TextArea { id: node + visible: Config.useGossip Layout.columnSpan: 2 Layout.fillWidth: true font.family: FixedFont @@ -37,6 +39,7 @@ Pane { } RowLayout { + visible: Config.useGossip spacing: 0 ToolButton { icon.source: '../../icons/paste.png' @@ -62,6 +65,18 @@ Pane { } } + // trampoline + ComboBox { + id: tnode + visible: !Config.useGossip + Layout.columnSpan: 3 + Layout.fillWidth: true + model: channelopener.trampolineNodeNames + onCurrentValueChanged: { + channelopener.nodeid = tnode.currentValue + } + } + Label { text: qsTr('Amount') } @@ -85,9 +100,7 @@ Pane { id: is_max text: qsTr('Max') onCheckedChanged: { - if (checked) { - channelopener.amount = MAX - } + channelopener.amount = checked ? MAX : Config.unitsToSats(amount.text) } } } diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index c1be7b0da..702ee379c 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -105,6 +105,22 @@ Pane { Daemon.fx.rateSource = currentValue } } + + Label { + text: qsTr('Lightning Routing') + enabled: Daemon.currentWallet.isLightning + } + + ComboBox { + id: lnRoutingType + valueRole: 'key' + textRole: 'label' + enabled: Daemon.currentWallet.isLightning && false + model: ListModel { + ListElement { key: 'gossip'; label: qsTr('Gossip') } + ListElement { key: 'trampoline'; label: qsTr('Trampoline') } + } + } } } @@ -118,5 +134,6 @@ Pane { historicRates.checked = Daemon.fx.historicRates rateSources.currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) fiatEnable.checked = Daemon.fx.enabled + lnRoutingType.currentIndex = Config.useGossip ? 0 : 1 } } diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index dde5c6c24..4d5eb1cab 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -2,7 +2,8 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger from electrum.util import format_time -from electrum.lnutil import extract_nodeid, ConnStringFormatError +from electrum.lnutil import extract_nodeid, ConnStringFormatError, LNPeerAddr +from electrum.lnworker import hardcoded_trampoline_nodes from electrum.gui import messages from .qewallet import QEWallet @@ -23,6 +24,8 @@ class QEChannelOpener(QObject): validationError = pyqtSignal([str,str], arguments=['code','message']) conflictingBackup = pyqtSignal([str], arguments=['message']) + dataChanged = pyqtSignal() # generic notify signal + walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) def wallet(self): @@ -69,14 +72,28 @@ class QEChannelOpener(QObject): def openTx(self): return self._opentx + @pyqtProperty(list, notify=dataChanged) + def trampolineNodeNames(self): + return list(hardcoded_trampoline_nodes().keys()) + + # FIXME min channel funding amount + # FIXME have requested funding amount def validate(self): nodeid_valid = False if self._nodeid: - try: - self._node_pubkey, self._host_port = extract_nodeid(self._nodeid) + if not self._wallet.wallet.config.get('use_gossip', False): + self._peer = hardcoded_trampoline_nodes()[self._nodeid] nodeid_valid = True - except ConnStringFormatError as e: - self.validationError.emit('invalid_nodeid', repr(e)) + else: + try: + node_pubkey, host_port = extract_nodeid(self._nodeid) + host, port = host_port.split(':',1) + self._peer = LNPeerAddr(host, int(port), node_pubkey) + nodeid_valid = True + except ConnStringFormatError as e: + self.validationError.emit('invalid_nodeid', repr(e)) + except ValueError as e: + self.validationError.emit('invalid_nodeid', repr(e)) if not nodeid_valid: self._valid = False @@ -99,16 +116,12 @@ class QEChannelOpener(QObject): if not self.valid: return - #if self.use_gossip: - #conn_str = self.pubkey - #if self.ipport: - #conn_str += '@' + self.ipport.strip() - #else: - #conn_str = str(self.trampolines[self.pubkey]) + self._logger.debug('Connect String: %s' % str(self._peer)) + amount = '!' if self._amount.isMax else self._amount.satsInt lnworker = self._wallet.wallet.lnworker - if lnworker.has_conflicting_backup_with(node_pubkey) and not confirm_backup_conflict: + if lnworker.has_conflicting_backup_with(self._peer.pubkey) and not confirm_backup_conflict: self.conflictingBackup.emit(messages.MGS_CONFLICTING_BACKUP_INSTANCE) return @@ -117,7 +130,7 @@ class QEChannelOpener(QObject): make_tx = lambda rbf: lnworker.mktx_for_open_channel( coins=coins, funding_sat=amount, - node_id=self._node_pubkey, + node_id=self._peer.pubkey, fee_est=None) #on_pay = lambda tx: self.app.protected('Create a new channel?', self.do_open_channel, (tx, conn_str)) #d = ConfirmTxDialog( diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 64ab26873..76bca9e64 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -70,6 +70,16 @@ class QEConfig(QObject): self.config.amt_add_thousands_sep = checked self.thousandsSeparatorChanged.emit() + useGossipChanged = pyqtSignal() + @pyqtProperty(bool, notify=useGossipChanged) + def useGossip(self): + return self.config.get('use_gossip', False) + + @useGossip.setter + def useGossip(self, gossip): + self.config.set_key('use_gossip', gossip) + self.useGossipChanged.emit() + @pyqtSlot('qint64', result=str) @pyqtSlot('qint64', bool, result=str) @pyqtSlot(QEAmount, result=str) diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index a49b97389..25f2c2d95 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -11,8 +11,10 @@ from .qewallet import QEWallet from .qetypes import QEAmount class QETxFinalizer(QObject): - def __init__(self, parent=None): + def __init__(self, parent=None, *, make_tx=None, accept=None): super().__init__(parent) + self.f_make_tx = make_tx + self.f_accept = accept self._tx = None _logger = get_logger(__name__) @@ -207,6 +209,11 @@ class QETxFinalizer(QObject): @profiler def make_tx(self): + if self.f_make_tx: + tx = self.f_make_tx() + return tx + + # default impl coins = self._wallet.wallet.get_spendable_coins(None) outputs = [PartialTxOutput.from_address_and_value(self.address, self._amount.satsInt)] tx = self._wallet.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None,rbf=self._rbf) @@ -268,4 +275,8 @@ class QETxFinalizer(QObject): self._logger.debug('no valid tx') return + if self.f_accept: + self.f_accept(self._tx) + return + self._wallet.sign_and_broadcast(self._tx) From dc693912044a96ee672b74c046f331911e10e825 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Jun 2022 16:55:59 +0200 Subject: [PATCH 151/218] initial working channel open flow --- electrum/gui/qml/components/OpenChannel.qml | 29 +++++++- electrum/gui/qml/qechannelopener.py | 73 +++++++++++---------- 2 files changed, 66 insertions(+), 36 deletions(-) diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml index b317b4963..9bc0d9943 100644 --- a/electrum/gui/qml/components/OpenChannel.qml +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -136,6 +136,16 @@ Pane { } } + Component { + id: confirmOpenChannelDialog + ConfirmTxDialog { + title: qsTr('Confirm Open Channel') + amountLabelText: qsTr('Channel capacity') + sendButtonText: qsTr('Open Channel') + finalizer: channelopener.finalizer + } + } + ChannelOpener { id: channelopener @@ -147,12 +157,29 @@ Pane { } } onConflictingBackup: { - var dialog = app.messageDialog.createObject(root, { 'text': message }) + var dialog = app.messageDialog.createObject(root, { 'text': message, 'yesno': true }) dialog.open() dialog.yesClicked.connect(function() { channelopener.open_channel(true) }) } + onFinalizerChanged: { + var dialog = confirmOpenChannelDialog.createObject(root, { + 'satoshis': channelopener.amount + }) + dialog.open() + } + onChannelOpenError: { + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + } + onChannelOpenSuccess: { + var message = 'success!' + if (!has_backup) + message = message = ' (but no backup. TODO: show QR)' + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + } } } diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index 4d5eb1cab..cadd54855 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -1,13 +1,14 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from electrum.i18n import _ from electrum.logging import get_logger -from electrum.util import format_time -from electrum.lnutil import extract_nodeid, ConnStringFormatError, LNPeerAddr +from electrum.lnutil import extract_nodeid, ConnStringFormatError, LNPeerAddr, ln_dummy_address from electrum.lnworker import hardcoded_trampoline_nodes from electrum.gui import messages from .qewallet import QEWallet from .qetypes import QEAmount +from .qetxfinalizer import QETxFinalizer class QEChannelOpener(QObject): def __init__(self, parent=None): @@ -23,6 +24,8 @@ class QEChannelOpener(QObject): validationError = pyqtSignal([str,str], arguments=['code','message']) conflictingBackup = pyqtSignal([str], arguments=['message']) + channelOpenError = pyqtSignal([str], arguments=['message']) + channelOpenSuccess = pyqtSignal([bool], arguments=['has_backup']) dataChanged = pyqtSignal() # generic notify signal @@ -67,10 +70,10 @@ class QEChannelOpener(QObject): def valid(self): return self._valid - openTxChanged = pyqtSignal() - @pyqtProperty(bool, notify=openTxChanged) - def openTx(self): - return self._opentx + finalizerChanged = pyqtSignal() + @pyqtProperty(QETxFinalizer, notify=finalizerChanged) + def finalizer(self): + return self._finalizer @pyqtProperty(list, notify=dataChanged) def trampolineNodeNames(self): @@ -118,45 +121,45 @@ class QEChannelOpener(QObject): self._logger.debug('Connect String: %s' % str(self._peer)) - amount = '!' if self._amount.isMax else self._amount.satsInt - lnworker = self._wallet.wallet.lnworker if lnworker.has_conflicting_backup_with(self._peer.pubkey) and not confirm_backup_conflict: self.conflictingBackup.emit(messages.MGS_CONFLICTING_BACKUP_INSTANCE) return + amount = '!' if self._amount.isMax else self._amount.satsInt coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True) - #node_id, rest = extract_nodeid(conn_str) - make_tx = lambda rbf: lnworker.mktx_for_open_channel( + + mktx = lambda: lnworker.mktx_for_open_channel( coins=coins, funding_sat=amount, node_id=self._peer.pubkey, fee_est=None) - #on_pay = lambda tx: self.app.protected('Create a new channel?', self.do_open_channel, (tx, conn_str)) - #d = ConfirmTxDialog( - #self.app, - #amount = amount, - #make_tx=make_tx, - #on_pay=on_pay, - #show_final=False) - #d.open() - - #def do_open_channel(self, funding_tx, conn_str, password): - ## read funding_sat from tx; converts '!' to int value - #funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) - #lnworker = self.app.wallet.lnworker - #try: - #chan, funding_tx = lnworker.open_channel( - #connect_str=conn_str, - #funding_tx=funding_tx, - #funding_sat=funding_sat, - #push_amt_sat=0, - #password=password) - #except Exception as e: - #self.app.logger.exception("Problem opening channel") - #self.app.show_error(_('Problem opening channel: ') + '\n' + repr(e)) - #return - ## TODO: it would be nice to show this before broadcasting + + acpt = lambda tx: self.do_open_channel(tx, str(self._peer), None) + + self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt) + self._finalizer.wallet = self._wallet + self.finalizerChanged.emit() + + def do_open_channel(self, funding_tx, conn_str, password): + # read funding_sat from tx; converts '!' to int value + funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) + lnworker = self._wallet.wallet.lnworker + try: + chan, funding_tx = lnworker.open_channel( + connect_str=conn_str, + funding_tx=funding_tx, + funding_sat=funding_sat, + push_amt_sat=0, + password=password) + except Exception as e: + self._logger.exception("Problem opening channel") + self.channelOpenError.emit(_('Problem opening channel: ') + '\n' + repr(e)) + return + + self.channelOpenSuccess.emit(chan.has_onchain_backup()) + + # TODO: it would be nice to show this before broadcasting #if chan.has_onchain_backup(): #self.maybe_show_funding_tx(chan, funding_tx) #else: From d69ed7a20412e9c4323e2321dfc186b3ea42deed Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 1 Jun 2022 16:57:07 +0200 Subject: [PATCH 152/218] initial channel list model and delegate --- electrum/gui/qml/components/Channels.qml | 30 +------ .../components/controls/ChannelDelegate.qml | 80 +++++++++++++++++++ electrum/gui/qml/qechannellistmodel.py | 38 +++++++-- electrum/gui/qml/qetransactionlistmodel.py | 2 - 4 files changed, 112 insertions(+), 38 deletions(-) create mode 100644 electrum/gui/qml/components/controls/ChannelDelegate.qml diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index 1939c6736..f4a88547f 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -89,36 +89,10 @@ Pane { Layout.preferredWidth: parent.width Layout.fillHeight: true clip: true - model: 3 //Daemon.currentWallet.channelsModel + model: Daemon.currentWallet.channelModel - delegate: ItemDelegate { - width: ListView.view.width - height: row.height + delegate: ChannelDelegate { highlighted: ListView.isCurrentItem - - font.pixelSize: constants.fontSizeMedium // set default font size for child controls - - RowLayout { - id: row - spacing: 10 - x: constants.paddingSmall - width: parent.width - 2 * constants.paddingSmall - - Image { - id: walleticon - source: "../../icons/lightning.png" - fillMode: Image.PreserveAspectFit - Layout.preferredWidth: constants.iconSizeLarge - Layout.preferredHeight: constants.iconSizeLarge - } - - Label { - font.pixelSize: constants.fontSizeLarge - text: index - Layout.fillWidth: true - } - - } } ScrollIndicator.vertical: ScrollIndicator { } diff --git a/electrum/gui/qml/components/controls/ChannelDelegate.qml b/electrum/gui/qml/components/controls/ChannelDelegate.qml new file mode 100644 index 000000000..a9d46da4b --- /dev/null +++ b/electrum/gui/qml/components/controls/ChannelDelegate.qml @@ -0,0 +1,80 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +ItemDelegate { + id: root + height: item.height + width: ListView.view.width + + font.pixelSize: constants.fontSizeSmall // set default font size for child controls + + GridLayout { + id: item + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingSmall + rightMargin: constants.paddingSmall + } + + columns: 2 + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + + Image { + id: walleticon + source: "../../../icons/lightning.png" + fillMode: Image.PreserveAspectFit + Layout.rowSpan: 2 + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.node_alias + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + } + + Label { + text: model.state + } + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.short_cid + } + + Label { + text: Config.formatSats(model.capacity) + } + + Label { + text: Config.baseUnit + } + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + + } +} diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 531ca0203..27910378c 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -19,35 +19,57 @@ class QEChannelListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('cid','state','initiator','capacity','can_send','can_receive', 'l_csv_delat','r_csv_delay','send_frozen','receive_frozen', - 'type','node_id','funding_tx') + 'type','node_id','node_alias','short_cid','funding_tx') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) def rowCount(self, index): - return len(self.tx_history) + return len(self.channels) def roleNames(self): return self._ROLE_MAP def data(self, index, role): - tx = self.tx_history[index.row()] + tx = self.channels[index.row()] role_index = role - Qt.UserRole value = tx[self._ROLE_NAMES[role_index]] if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: return value if isinstance(value, Satoshis): return value.value - if isinstance(value, QEAmount): - return value return str(value) + def clear(self): + self.beginResetModel() + self.channels = [] + self.endResetModel() + + def channel_to_model(self, lnc): + lnworker = self.wallet.lnworker + item = {} + item['node_alias'] = lnworker.get_node_alias(lnc.node_id) or lnc.node_id.hex() + item['short_cid'] = lnc.short_id_for_GUI() + item['state'] = lnc.get_state_for_GUI() + item['capacity'] = QEAmount(amount_sat=lnc.get_capacity()) + self._logger.debug(repr(item)) + return item + @pyqtSlot() def init_model(self): if not self.wallet.lnworker: self._logger.warning('lnworker should be defined') return - channels = self.wallet.lnworker.channels - self._logger.debug(repr(channels)) - #channels = list(lnworker.channels.values()) if lnworker else [] + channels = [] + + lnchannels = self.wallet.lnworker.channels + for channel in lnchannels.values(): + self._logger.debug(repr(channel)) + item = self.channel_to_model(channel) + channels.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, len(channels) - 1) + self.channels = channels + self.endInsertRows() diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 71d474afd..85574fce2 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -38,8 +38,6 @@ class QETransactionListModel(QAbstractListModel): return value if isinstance(value, Satoshis): return value.value - if isinstance(value, QEAmount): - return value return str(value) def clear(self): From 3c1926c3c27ccf1bfc36664a7680988e2755f3f7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 2 Jun 2022 11:59:01 +0200 Subject: [PATCH 153/218] some fixes, add lightning balance to BalanceSummary --- .../gui/qml/components/BalanceSummary.qml | 34 +++++++++++++++++++ .../gui/qml/components/controls/BtcField.qml | 4 ++- electrum/gui/qml/qetypes.py | 3 ++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index 03d934a20..fe6b77231 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -12,13 +12,17 @@ Frame { property string formattedUnconfirmed property string formattedBalanceFiat property string formattedUnconfirmedFiat + property string formattedLightningBalance + property string formattedLightningBalanceFiat function setBalances() { root.formattedBalance = Config.formatSats(Daemon.currentWallet.confirmedBalance) root.formattedUnconfirmed = Config.formatSats(Daemon.currentWallet.unconfirmedBalance) + root.formattedLightningBalance = Config.formatSats(Daemon.currentWallet.lightningBalance) if (Daemon.fx.enabled) { root.formattedBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.confirmedBalance, false) root.formattedUnconfirmedFiat = Daemon.fx.fiatValue(Daemon.currentWallet.unconfirmedBalance, false) + root.formattedLightningBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.lightningBalance, false) } } @@ -92,6 +96,30 @@ Frame { : '' } } + Label { + visible: Daemon.currentWallet.isLightning + font.pixelSize: constants.fontSizeSmall + text: qsTr('Lightning: ') + } + RowLayout { + visible: Daemon.currentWallet.isLightning + Label { + font.pixelSize: constants.fontSizeSmall + font.family: FixedFont + text: formattedLightningBalance + } + Label { + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeSmall + text: Daemon.fx.enabled + ? '(' + root.formattedLightningBalanceFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } } // instead of all these explicit connections, we should expose @@ -107,6 +135,12 @@ Frame { function onWalletLoaded() { setBalances() } } + Connections { + target: Daemon.fx + function onEnabledUpdated() { setBalances() } + function onQuotesUpdated() { setBalances() } + } + Connections { target: Daemon.currentWallet function onBalanceChanged() { diff --git a/electrum/gui/qml/components/controls/BtcField.qml b/electrum/gui/qml/components/controls/BtcField.qml index 552c5fc77..9436542c1 100644 --- a/electrum/gui/qml/components/controls/BtcField.qml +++ b/electrum/gui/qml/components/controls/BtcField.qml @@ -22,7 +22,9 @@ TextField { Connections { target: Config function onBaseUnitChanged() { - amount.text = amount.textAsSats != 0 ? Config.satsToUnits(amount.textAsSats) : '' + amount.text = amount.textAsSats.satsInt != 0 + ? Config.satsToUnits(amount.textAsSats) + : '' } } } diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py index cf8c8faba..baba6e219 100644 --- a/electrum/gui/qml/qetypes.py +++ b/electrum/gui/qml/qetypes.py @@ -58,3 +58,6 @@ class QEAmount(QObject): if self._is_max: return '%s(MAX)' % s return '%s(sats=%d, msats=%d)' % (s, self._amount_sat, self._amount_msat) + + def __repr__(self): + return f"" From 7ef52c66258aa5d0c055d0c75f4c3f0a76dc4b32 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 2 Jun 2022 12:00:06 +0200 Subject: [PATCH 154/218] listmodels self-initialize, lazy-load listmodels in QEWallet, process channel updates in qechannellistmodel --- electrum/gui/qml/components/Addresses.qml | 1 - electrum/gui/qml/components/Channels.qml | 1 - electrum/gui/qml/qeaddresslistmodel.py | 4 +- electrum/gui/qml/qechannellistmodel.py | 43 +++++++++++++++++++++- electrum/gui/qml/qeinvoicelistmodel.py | 2 +- electrum/gui/qml/qetransactionlistmodel.py | 2 +- electrum/gui/qml/qewallet.py | 23 ++++++++---- 7 files changed, 59 insertions(+), 17 deletions(-) diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index c4d249da4..daef18775 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -169,5 +169,4 @@ Pane { } } - Component.onCompleted: Daemon.currentWallet.addressModel.init_model() } diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index f4a88547f..ada4c4693 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -102,5 +102,4 @@ Pane { } - Component.onCompleted: Daemon.currentWallet.channelModel.init_model() } diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index eb042b8f2..e0d9dceab 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -10,9 +10,7 @@ class QEAddressListModel(QAbstractListModel): def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet - self.receive_addresses = [] - self.change_addresses = [] - + self.init_model() _logger = get_logger(__name__) diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 27910378c..8d6e266b2 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -4,7 +4,7 @@ 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, TxMinedInfo +from electrum.util import Satoshis, register_callback from .qetypes import QEAmount @@ -12,7 +12,16 @@ class QEChannelListModel(QAbstractListModel): def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet - self.channels = [] + self.init_model() + + interests = ['channel', 'channels_updated', 'gossip_peers', + 'ln_gossip_sync_progress', 'unknown_channels', + 'channel_db', 'gossip_db_loaded'] + # To avoid leaking references to "self" that prevent the + # window from being GC-ed when closed, callbacks should be + # methods of this class only, and specifically not be + # partials, lambdas or methods of subobjects. Hence... + register_callback(self.on_network, interests) _logger = get_logger(__name__) @@ -48,6 +57,7 @@ class QEChannelListModel(QAbstractListModel): def channel_to_model(self, lnc): lnworker = self.wallet.lnworker item = {} + item['channel_id'] = lnc.channel_id item['node_alias'] = lnworker.get_node_alias(lnc.node_id) or lnc.node_id.hex() item['short_cid'] = lnc.short_id_for_GUI() item['state'] = lnc.get_state_for_GUI() @@ -57,6 +67,7 @@ class QEChannelListModel(QAbstractListModel): @pyqtSlot() def init_model(self): + self._logger.debug('init_model') if not self.wallet.lnworker: self._logger.warning('lnworker should be defined') return @@ -73,3 +84,31 @@ class QEChannelListModel(QAbstractListModel): self.beginInsertRows(QModelIndex(), 0, len(channels) - 1) self.channels = channels self.endInsertRows() + + def on_network(self, event, *args): + if event == 'channel': + wallet, channel = args + if wallet == self.wallet: + self.on_channel_updated(channel) + elif event == 'channels_updated': + wallet, = args + if wallet == self.wallet: + self.init_model() # TODO: remove/add less crude than full re-init + else: + self._logger.debug('unhandled event %s: %s' % (event, repr(args))) + + def on_channel_updated(self, channel): + i = 0 + for c in self.channels: + if c['channel_id'] == channel.channel_id: + self.do_update(i,channel) + break + i = i + 1 + + def do_update(self, modelindex, channel): + modelitem = self.channels[modelindex] + self._logger.debug(repr(modelitem)) + modelitem.update(self.channel_to_model(channel)) + + mi = self.createIndex(modelindex, 0) + self.dataChanged.emit(mi, mi, self._ROLE_KEYS) diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 91eec07ad..ef041c96b 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -15,7 +15,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet - self.invoices = [] + self.init_model() # define listmodel rolemap _ROLE_NAMES=('key','is_lightning','timestamp','date','message','amount','status','status_str','address','expiration','type') diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 85574fce2..8d8fb0e92 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -12,7 +12,7 @@ class QETransactionListModel(QAbstractListModel): def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet - self.tx_history = [] + self.init_model() _logger = get_logger(__name__) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 08c9bc8b7..3e08b7a25 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -57,16 +57,12 @@ class QEWallet(QObject): super().__init__(parent) self.wallet = wallet - self._historyModel = QETransactionListModel(wallet) - self._addressModel = QEAddressListModel(wallet) - self._requestModel = QERequestListModel(wallet) - self._invoiceModel = QEInvoiceListModel(wallet) + self._historyModel = None + self._addressModel = None + self._requestModel = None + self._invoiceModel = None self._channelModel = None - self._historyModel.init_model() - self._requestModel.init_model() - self._invoiceModel.init_model() - self.tx_notification_queue = queue.Queue() self.tx_notification_last_time = 0 @@ -125,6 +121,9 @@ class QEWallet(QObject): if wallet == self.wallet: self._logger.debug('wallet %s updated' % str(wallet)) self.balanceChanged.emit() + elif event in ['channel','channels_updated']: + # TODO update balance/can-spend etc + pass else: self._logger.debug('unhandled event: %s %s' % (event, str(args))) @@ -177,21 +176,29 @@ class QEWallet(QObject): historyModelChanged = pyqtSignal() @pyqtProperty(QETransactionListModel, notify=historyModelChanged) def historyModel(self): + if self._historyModel is None: + self._historyModel = QETransactionListModel(self.wallet) return self._historyModel addressModelChanged = pyqtSignal() @pyqtProperty(QEAddressListModel, notify=addressModelChanged) def addressModel(self): + if self._addressModel is None: + self._addressModel = QEAddressListModel(self.wallet) return self._addressModel requestModelChanged = pyqtSignal() @pyqtProperty(QERequestListModel, notify=requestModelChanged) def requestModel(self): + if self._requestModel is None: + self._requestModel = QERequestListModel(self.wallet) return self._requestModel invoiceModelChanged = pyqtSignal() @pyqtProperty(QEInvoiceListModel, notify=invoiceModelChanged) def invoiceModel(self): + if self._invoiceModel is None: + self._invoiceModel = QEInvoiceListModel(self.wallet) return self._invoiceModel channelModelChanged = pyqtSignal() From c3db1e5cc13d34c81043626b99742a0a5edd5d4b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 2 Jun 2022 16:35:31 +0200 Subject: [PATCH 155/218] add can send/can receive (totals and per-channel), fiat, channel ratio indicator --- electrum/gui/qml/components/Channels.qml | 53 +++++++++++++----- electrum/gui/qml/components/Constants.qml | 4 ++ .../components/controls/ChannelDelegate.qml | 42 ++++++++++++++- electrum/gui/qml/qechannellistmodel.py | 54 ++++++++++++------- electrum/gui/qml/qewallet.py | 26 +++++++-- 5 files changed, 141 insertions(+), 38 deletions(-) diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index ada4c4693..5462edc7f 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -22,7 +22,8 @@ Pane { Label { Layout.columnSpan: 2 - text: '' + text: qsTr('You have %1 open channels').arg(listview.count) + color: Material.accentColor } Label { @@ -30,8 +31,20 @@ Pane { color: Material.accentColor } - Label { - text: '' + RowLayout { + Layout.fillWidth: true + Label { + text: Config.formatSats(Daemon.currentWallet.lightningCanSend) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(Daemon.currentWallet.lightningCanSend) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } } Label { @@ -39,20 +52,23 @@ Pane { color: Material.accentColor } - Label { - text: '' - } - RowLayout { - Layout.columnSpan: 2 - - Button { - text: qsTr('Open Channel') - onClicked: app.stack.push(Qt.resolvedUrl('OpenChannel.qml')) + Layout.fillWidth: true + Label { + text: Config.formatSats(Daemon.currentWallet.lightningCanReceive) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(Daemon.currentWallet.lightningCanReceive) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' } } - } + } Frame { id: channelsFrame @@ -92,7 +108,7 @@ Pane { model: Daemon.currentWallet.channelModel delegate: ChannelDelegate { - highlighted: ListView.isCurrentItem + //highlighted: ListView.isCurrentItem } ScrollIndicator.vertical: ScrollIndicator { } @@ -100,6 +116,15 @@ Pane { } } + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Button { + text: qsTr('Open Channel') + onClicked: app.stack.push(Qt.resolvedUrl('OpenChannel.qml')) + } + } + } } diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index bd4d9e0ea..da633b95b 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -27,4 +27,8 @@ Item { property color colorDebit: "#ffff8080" property color mutedForeground: 'gray' //Qt.lighter(Material.background, 2) property color colorMine: "yellow" + + property color colorLightningLocal: "blue" + property color colorLightningRemote: "yellow" + } diff --git a/electrum/gui/qml/components/controls/ChannelDelegate.qml b/electrum/gui/qml/components/controls/ChannelDelegate.qml index a9d46da4b..b8aed2a51 100644 --- a/electrum/gui/qml/components/controls/ChannelDelegate.qml +++ b/electrum/gui/qml/components/controls/ChannelDelegate.qml @@ -33,7 +33,7 @@ ItemDelegate { id: walleticon source: "../../../icons/lightning.png" fillMode: Image.PreserveAspectFit - Layout.rowSpan: 2 + Layout.rowSpan: 3 Layout.preferredWidth: constants.iconSizeLarge Layout.preferredHeight: constants.iconSizeLarge } @@ -58,17 +58,57 @@ ItemDelegate { Label { Layout.fillWidth: true text: model.short_cid + color: constants.mutedForeground } Label { text: Config.formatSats(model.capacity) + font.family: FixedFont } Label { text: Config.baseUnit + color: Material.accentColor } } + Item { + id: chviz + Layout.fillWidth: true + height: 10 + onWidthChanged: { + var cap = model.capacity.satsInt * 1000 + var twocap = cap * 2 + b1.width = width * (cap - model.can_send.msatsInt) / twocap + b2.width = width * model.can_send.msatsInt / twocap + b3.width = width * model.can_receive.msatsInt / twocap + b4.width = width * (cap - model.can_receive.msatsInt) / twocap + } + Rectangle { + id: b1 + x: 0 + height: parent.height + color: 'gray' + } + Rectangle { + id: b2 + anchors.left: b1.right + height: parent.height + color: constants.colorLightningLocal + } + Rectangle { + id: b3 + anchors.left: b2.right + height: parent.height + color: constants.colorLightningRemote + } + Rectangle { + id: b4 + anchors.left: b3.right + height: parent.height + color: 'gray' + } + } Rectangle { Layout.columnSpan: 2 Layout.fillWidth: true diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 8d6e266b2..77a05cc2b 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -5,15 +5,29 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.logging import get_logger from electrum.util import Satoshis, register_callback +from electrum.lnutil import LOCAL, REMOTE from .qetypes import QEAmount class QEChannelListModel(QAbstractListModel): + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('cid','state','initiator','capacity','can_send','can_receive', + 'l_csv_delat','r_csv_delay','send_frozen','receive_frozen', + 'type','node_id','node_alias','short_cid','funding_tx') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) + + _network_signal = pyqtSignal(str, object) + def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet self.init_model() + self._network_signal.connect(self.on_network_qt) interests = ['channel', 'channels_updated', 'gossip_peers', 'ln_gossip_sync_progress', 'unknown_channels', 'channel_db', 'gossip_db_loaded'] @@ -23,15 +37,25 @@ class QEChannelListModel(QAbstractListModel): # partials, lambdas or methods of subobjects. Hence... register_callback(self.on_network, interests) - _logger = get_logger(__name__) + def on_network(self, event, *args): + if event == 'channel': + # Handle in GUI thread (_network_signal -> on_network_qt) + self._network_signal.emit(event, args) + else: + self.on_network_qt(event, args) + + def on_network_qt(self, event, args=None): + if event == 'channel': + wallet, channel = args + if wallet == self.wallet: + self.on_channel_updated(channel) + elif event == 'channels_updated': + wallet, = args + if wallet == self.wallet: + self.init_model() # TODO: remove/add less crude than full re-init + else: + self._logger.debug('unhandled event %s: %s' % (event, repr(args))) - # define listmodel rolemap - _ROLE_NAMES=('cid','state','initiator','capacity','can_send','can_receive', - 'l_csv_delat','r_csv_delay','send_frozen','receive_frozen', - 'type','node_id','node_alias','short_cid','funding_tx') - _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) - _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) - _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) def rowCount(self, index): return len(self.channels) @@ -62,6 +86,8 @@ class QEChannelListModel(QAbstractListModel): item['short_cid'] = lnc.short_id_for_GUI() item['state'] = lnc.get_state_for_GUI() item['capacity'] = QEAmount(amount_sat=lnc.get_capacity()) + item['can_send'] = QEAmount(amount_msat=lnc.available_to_spend(LOCAL)) + item['can_receive'] = QEAmount(amount_msat=lnc.available_to_spend(REMOTE)) self._logger.debug(repr(item)) return item @@ -85,18 +111,6 @@ class QEChannelListModel(QAbstractListModel): self.channels = channels self.endInsertRows() - def on_network(self, event, *args): - if event == 'channel': - wallet, channel = args - if wallet == self.wallet: - self.on_channel_updated(channel) - elif event == 'channels_updated': - wallet, = args - if wallet == self.wallet: - self.init_model() # TODO: remove/add less crude than full re-init - else: - self._logger.debug('unhandled event %s: %s' % (event, repr(args))) - def on_channel_updated(self, channel): i = 0 for c in self.channels: diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 3e08b7a25..28af7692a 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -121,9 +121,14 @@ class QEWallet(QObject): if wallet == self.wallet: self._logger.debug('wallet %s updated' % str(wallet)) self.balanceChanged.emit() - elif event in ['channel','channels_updated']: - # TODO update balance/can-spend etc - pass + elif event == 'channel': + wallet, channel = args + if wallet == self.wallet: + self.balanceChanged.emit() + elif event == 'channels_updated': + wallet, = args + if wallet == self.wallet: + self.balanceChanged.emit() else: self._logger.debug('unhandled event: %s %s' % (event, str(args))) @@ -279,6 +284,21 @@ class QEWallet(QObject): self._lightningbalance = QEAmount(amount_sat=self.wallet.lnworker.get_balance()) return self._lightningbalance + @pyqtProperty(QEAmount, notify=balanceChanged) + def lightningCanSend(self): + if not self.isLightning: + return QEAmount() + self._lightningcansend = QEAmount(amount_sat=self.wallet.lnworker.num_sats_can_send()) + return self._lightningcansend + + @pyqtProperty(QEAmount, notify=balanceChanged) + def lightningCanReceive(self): + if not self.isLightning: + return QEAmount() + self._lightningcanreceive = QEAmount(amount_sat=self.wallet.lnworker.num_sats_can_receive()) + return self._lightningcanreceive + + @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): self._logger.info('send_onchain: %s %d' % (address,amount)) From 6a9df9b66579152ccb8694b4347869efeaae1d45 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 2 Jun 2022 16:44:25 +0200 Subject: [PATCH 156/218] fix one init_model call on a potentially undefined _addressModel --- electrum/gui/qml/qewallet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 28af7692a..06df648a9 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -395,7 +395,7 @@ class QEWallet(QObject): key = self.create_bitcoin_request(amount.satsInt, message, expiration, ignore_gap) if not key: return - self._addressModel.init_model() + self.addressModel.init_model() except InvoiceError as e: self.requestCreateError.emit('fatal',_('Error creating payment request') + ':\n' + str(e)) return From 3046c0bbaeb790abb145a8c4664af3a0307a2ae3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 3 Jun 2022 13:26:45 +0200 Subject: [PATCH 157/218] rename ConfirmInvoiceDialog to InvoiceDialog and style buttons like RequestDialog --- ...irmInvoiceDialog.qml => InvoiceDialog.qml} | 22 +++++++++++++------ electrum/gui/qml/components/RequestDialog.qml | 2 +- electrum/gui/qml/components/Send.qml | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) rename electrum/gui/qml/components/{ConfirmInvoiceDialog.qml => InvoiceDialog.qml} (86%) diff --git a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml similarity index 86% rename from electrum/gui/qml/components/ConfirmInvoiceDialog.qml rename to electrum/gui/qml/components/InvoiceDialog.qml index 6a18bc583..cb9f5a848 100644 --- a/electrum/gui/qml/components/ConfirmInvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -19,6 +19,7 @@ Dialog { height: parent.height title: qsTr('Invoice') + standardButtons: invoice_key != '' ? Dialog.Close : Dialog.Cancel modal: true parent: Overlay.overlay @@ -60,7 +61,6 @@ Dialog { text: invoice.message Layout.fillWidth: true wrapMode: Text.Wrap - maximumLineCount: 4 elide: Text.ElideRight } @@ -98,7 +98,14 @@ Dialog { text: invoice.status_str } - Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Item { Layout.preferredHeight: constants.paddingLarge; Layout.preferredWidth: 1 } RowLayout { Layout.columnSpan: 2 @@ -107,6 +114,7 @@ Dialog { Button { text: qsTr('Delete') + icon.source: '../../icons/delete.png' visible: invoice_key != '' onClicked: { invoice.wallet.delete_invoice(invoice_key) @@ -114,13 +122,10 @@ Dialog { } } - Button { - text: qsTr('Cancel') - onClicked: dialog.close() - } - Button { text: qsTr('Save') + icon.source: '../../icons/save.png' + visible: invoice_key == '' enabled: invoice.invoiceType == Invoice.OnchainInvoice onClicked: { invoice.save_invoice() @@ -130,6 +135,7 @@ Dialog { Button { text: qsTr('Pay now') + icon.source: '../../icons/confirmed.png' enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds onClicked: { invoice.save_invoice() @@ -141,6 +147,8 @@ Dialog { } } + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + } Component.onCompleted: { diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index e0aa4af40..5f21f70b8 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -15,7 +15,7 @@ Dialog { parent: Overlay.overlay modal: true - standardButtons: Dialog.Ok + standardButtons: Dialog.Close width: parent.width height: parent.height diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 495656a58..32422390f 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -228,7 +228,7 @@ Pane { Component { id: confirmInvoiceDialog - ConfirmInvoiceDialog { + InvoiceDialog { onDoPay: { if (invoice.invoiceType == Invoice.OnchainInvoice) { var dialog = confirmPaymentDialog.createObject(rootItem, { From 3fd33169f54636274b794cd721a636b44a93b860 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 3 Jun 2022 15:19:24 +0200 Subject: [PATCH 158/218] frontend improvements, refactor qeinvoicelistmodel --- electrum/gui/qml/components/Receive.qml | 4 +- electrum/gui/qml/components/RequestDialog.qml | 9 +++- electrum/gui/qml/components/Send.qml | 12 +++-- .../gui/qml/components/controls/FiatField.qml | 4 +- .../components/controls/InvoiceDelegate.qml | 26 +++++++--- electrum/gui/qml/qebitcoin.py | 2 +- electrum/gui/qml/qeinvoicelistmodel.py | 51 +++++++++++-------- electrum/gui/qml/qetypes.py | 15 +++++- 8 files changed, 83 insertions(+), 40 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 92507eccb..44cbf0f1c 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -39,7 +39,7 @@ Pane { BtcField { id: amount fiatfield: amountFiat - Layout.preferredWidth: parent.width /2 + Layout.preferredWidth: parent.width /3 } Label { @@ -55,7 +55,7 @@ Pane { id: amountFiat btcfield: amount visible: Daemon.fx.enabled - Layout.preferredWidth: parent.width /2 + Layout.preferredWidth: parent.width /3 } Label { diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 5f21f70b8..437602077 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -164,10 +164,12 @@ Dialog { Label { text: qsTr('Address') + visible: !modelItem.is_lightning } Label { Layout.fillWidth: true Layout.columnSpan: 3 + visible: !modelItem.is_lightning font.family: FixedFont font.pixelSize: constants.fontSizeLarge wrapMode: Text.WrapAnywhere @@ -175,6 +177,7 @@ Dialog { } ToolButton { icon.source: '../../icons/copy_bw.png' + visible: !modelItem.is_lightning onClicked: { AppController.textToClipboard(modelItem.address) } @@ -203,8 +206,10 @@ Dialog { } Component.onCompleted: { - _bip21uri = bitcoin.create_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp) - qr.source = 'image://qrgen/' + _bip21uri + if (!modelItem.is_lightning) { + _bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp) + qr.source = 'image://qrgen/' + _bip21uri + } } Bitcoin { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 32422390f..02deca968 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -74,7 +74,7 @@ Pane { BtcField { id: amount fiatfield: amountFiat - Layout.preferredWidth: parent.width /2 + Layout.preferredWidth: parent.width /3 } Label { @@ -91,7 +91,7 @@ Pane { id: amountFiat btcfield: amount visible: Daemon.fx.enabled - Layout.preferredWidth: parent.width /2 + Layout.preferredWidth: parent.width /3 } Label { @@ -123,6 +123,7 @@ Pane { Button { text: qsTr('Save') enabled: invoice.invoiceType != Invoice.Invalid + icon.source: '../../icons/save.png' onClicked: { Daemon.currentWallet.create_invoice(recipient.text, amount.text, message.text) } @@ -131,6 +132,7 @@ Pane { Button { text: qsTr('Pay now') enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds + icon.source: '../../icons/confirmed.png' onClicked: { var f_amount = parseFloat(amount.text) if (isNaN(f_amount)) @@ -193,7 +195,7 @@ Pane { model: Daemon.currentWallet.invoiceModel delegate: InvoiceDelegate { onClicked: { - var dialog = confirmInvoiceDialog.createObject(app, {'invoice' : invoice, 'invoice_key': model.key}) + var dialog = invoiceDialog.createObject(app, {'invoice' : invoice, 'invoice_key': model.key}) dialog.open() } } @@ -227,7 +229,7 @@ Pane { } Component { - id: confirmInvoiceDialog + id: invoiceDialog InvoiceDialog { onDoPay: { if (invoice.invoiceType == Invoice.OnchainInvoice) { @@ -285,7 +287,7 @@ Pane { if (invoiceType == Invoice.OnchainOnlyAddress) recipient.text = invoice.recipient else { - var dialog = confirmInvoiceDialog.createObject(rootItem, {'invoice': invoice}) + var dialog = invoiceDialog.createObject(rootItem, {'invoice': invoice}) dialog.open() } } diff --git a/electrum/gui/qml/components/controls/FiatField.qml b/electrum/gui/qml/components/controls/FiatField.qml index 59cb0f180..3c2ed376c 100644 --- a/electrum/gui/qml/components/controls/FiatField.qml +++ b/electrum/gui/qml/components/controls/FiatField.qml @@ -13,7 +13,9 @@ TextField { inputMethodHints: Qt.ImhPreferNumbers onTextChanged: { if (amountFiat.activeFocus) - btcfield.text = text == '' ? '' : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) + btcfield.text = text == '' + ? '' + : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) } Connections { diff --git a/electrum/gui/qml/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml index 67e9c0239..4e6cd40f0 100644 --- a/electrum/gui/qml/components/controls/InvoiceDelegate.qml +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -36,6 +36,18 @@ ItemDelegate { source: model.is_lightning ? "../../../icons/lightning.png" : "../../../icons/bitcoin.png" + + Image { + visible: model.onchain_fallback + z: -1 + source: "../../../icons/bitcoin.png" + anchors { + right: parent.right + bottom: parent.bottom + } + width: parent.width /2 + height: parent.height /2 + } } RowLayout { @@ -55,13 +67,13 @@ ItemDelegate { Label { id: amount - text: model.amount == 0 ? '' : Config.formatSats(model.amount) + text: model.amount.isEmpty ? '' : Config.formatSats(model.amount) font.pixelSize: constants.fontSizeMedium font.family: FixedFont } Label { - text: model.amount == 0 ? '' : Config.baseUnit + text: model.amount.isEmpty ? '' : Config.baseUnit font.pixelSize: constants.fontSizeMedium color: Material.accentColor } @@ -95,14 +107,14 @@ ItemDelegate { id: fiatValue visible: Daemon.fx.enabled Layout.alignment: Qt.AlignRight - text: model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) + text: model.amount.isEmpty ? '' : Daemon.fx.fiatValue(model.amount, false) font.family: FixedFont font.pixelSize: constants.fontSizeSmall } Label { visible: Daemon.fx.enabled Layout.alignment: Qt.AlignRight - text: model.amount == 0 ? '' : Daemon.fx.fiatCurrency + text: model.amount.isEmpty ? '' : Daemon.fx.fiatCurrency font.pixelSize: constants.fontSizeSmall color: Material.accentColor } @@ -119,16 +131,16 @@ ItemDelegate { Connections { target: Config function onBaseUnitChanged() { - amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) + amount.text = model.amount.isEmpty ? '' : Config.formatSats(model.amount) } function onThousandsSeparatorChanged() { - amount.text = model.amount == 0 ? '' : Config.formatSats(model.amount) + amount.text = model.amount.isEmpty ? '' : Config.formatSats(model.amount) } } Connections { target: Daemon.fx function onQuotesUpdated() { - fiatValue.text = model.amount == 0 ? '' : Daemon.fx.fiatValue(model.amount, false) + fiatValue.text = model.amount.isEmpty ? '' : Daemon.fx.fiatValue(model.amount, false) } } diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 2dbfaf34e..eb9d1625f 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -123,7 +123,7 @@ class QEBitcoin(QObject): return { 'error': str(e) } @pyqtSlot(str, QEAmount, str, int, int, result=str) - def create_uri(self, address, satoshis, message, timestamp, expiry): + def create_bip21_uri(self, address, satoshis, message, timestamp, expiry): extra_params = {} if expiry: extra_params['time'] = str(timestamp) diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index ef041c96b..8fa464e9c 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -18,7 +18,8 @@ class QEAbstractInvoiceListModel(QAbstractListModel): self.init_model() # define listmodel rolemap - _ROLE_NAMES=('key','is_lightning','timestamp','date','message','amount','status','status_str','address','expiration','type') + _ROLE_NAMES=('key', 'is_lightning', 'timestamp', 'date', 'message', 'amount', + 'status', 'status_str', 'address', 'expiration', 'type', 'onchain_fallback') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -96,6 +97,19 @@ class QEAbstractInvoiceListModel(QAbstractListModel): return i = i + 1 + def invoice_to_model(self, invoice: Invoice): + item = self.get_invoice_as_dict(invoice) + item['key'] = invoice.get_id() + item['is_lightning'] = invoice.is_lightning() + if invoice.is_lightning() and 'address' not in item: + item['address'] = '' + item['date'] = format_time(item['timestamp']) + item['amount'] = QEAmount(from_invoice=invoice) + item['onchain_fallback'] = invoice.is_lightning() and invoice._lnaddr.get_fallback_address() + item['type'] = 'invoice' + + return item + @abstractmethod def get_invoice_for_key(self, key: str): raise Exception('provide impl') @@ -105,55 +119,52 @@ class QEAbstractInvoiceListModel(QAbstractListModel): raise Exception('provide impl') @abstractmethod - def invoice_to_model(self, invoice: Invoice): + def get_invoice_as_dict(self, invoice: Invoice): raise Exception('provide impl') + class QEInvoiceListModel(QEAbstractInvoiceListModel): def __init__(self, wallet, parent=None): super().__init__(wallet, parent) _logger = get_logger(__name__) - def get_invoice_list(self): - return self.wallet.get_unpaid_invoices() - def invoice_to_model(self, invoice: Invoice): - item = self.wallet.export_invoice(invoice) - item['is_lightning'] = invoice.is_lightning() - item['date'] = format_time(item['timestamp']) - item['amount'] = QEAmount(amount_sat=invoice.get_amount_sat()) - item['key'] = invoice.get_id() - + item = super().invoice_to_model(invoice) item['type'] = 'invoice' return item + def get_invoice_list(self): + return self.wallet.get_unpaid_invoices() + def get_invoice_for_key(self, key: str): return self.wallet.get_invoice(key) + def get_invoice_as_dict(self, invoice: Invoice): + return self.wallet.export_invoice(invoice) + class QERequestListModel(QEAbstractInvoiceListModel): def __init__(self, wallet, parent=None): super().__init__(wallet, parent) _logger = get_logger(__name__) - def get_invoice_list(self): - return self.wallet.get_unpaid_requests() - def invoice_to_model(self, req: Invoice): - item = self.wallet.export_request(req) - item['key'] = req.get_rhash() if req.is_lightning() else req.get_address() - item['is_lightning'] = req.is_lightning() - item['date'] = format_time(item['timestamp']) - item['amount'] = QEAmount(amount_sat=req.get_amount_sat()) - + item = super().invoice_to_model(req) item['type'] = 'request' return item + def get_invoice_list(self): + return self.wallet.get_unpaid_requests() + def get_invoice_for_key(self, key: str): return self.wallet.get_request(key) + def get_invoice_as_dict(self, req: Invoice): + return self.wallet.export_request(req) + @pyqtSlot(str, int) def updateRequest(self, key, status): self.updateInvoice(key, status) diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py index baba6e219..641f9e03d 100644 --- a/electrum/gui/qml/qetypes.py +++ b/electrum/gui/qml/qetypes.py @@ -15,11 +15,18 @@ from electrum.util import profiler class QEAmount(QObject): _logger = get_logger(__name__) - def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, parent=None): + def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, from_invoice = None, parent=None): super().__init__(parent) self._amount_sat = amount_sat self._amount_msat = amount_msat self._is_max = is_max + if from_invoice: + inv_amt = from_invoice.get_amount_msat() + if inv_amt == '!': + self._is_max = True + elif inv_amt is not None: + self._amount_msat = inv_amt + self._amount_sat = from_invoice.get_amount_sat() valueChanged = pyqtSignal() @@ -43,6 +50,10 @@ class QEAmount(QObject): def isMax(self): return self._is_max + @pyqtProperty(bool, notify=valueChanged) + def isEmpty(self): + return not(self._is_max or self._amount_sat or self._amount_msat) + def __eq__(self, other): if isinstance(other, QEAmount): return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max @@ -60,4 +71,4 @@ class QEAmount(QObject): return '%s(sats=%d, msats=%d)' % (s, self._amount_sat, self._amount_msat) def __repr__(self): - return f"" + return f"" From d7c8a1592ea098f91811ea3ad7451cc08223cd6a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 3 Jun 2022 21:23:43 +0200 Subject: [PATCH 159/218] the invoice/request key issue is annoying --- electrum/gui/qml/qeinvoicelistmodel.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 8fa464e9c..ea97ae9bf 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -99,7 +99,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): def invoice_to_model(self, invoice: Invoice): item = self.get_invoice_as_dict(invoice) - item['key'] = invoice.get_id() + #item['key'] = invoice.get_id() item['is_lightning'] = invoice.is_lightning() if invoice.is_lightning() and 'address' not in item: item['address'] = '' @@ -132,6 +132,7 @@ class QEInvoiceListModel(QEAbstractInvoiceListModel): def invoice_to_model(self, invoice: Invoice): item = super().invoice_to_model(invoice) item['type'] = 'invoice' + item['key'] = invoice.get_id() return item @@ -150,9 +151,10 @@ class QERequestListModel(QEAbstractInvoiceListModel): _logger = get_logger(__name__) - def invoice_to_model(self, req: Invoice): - item = super().invoice_to_model(req) + def invoice_to_model(self, invoice: Invoice): + item = super().invoice_to_model(invoice) item['type'] = 'request' + item['key'] = invoice.get_id() if invoice.is_lightning() else invoice.get_address() return item @@ -162,8 +164,8 @@ class QERequestListModel(QEAbstractInvoiceListModel): def get_invoice_for_key(self, key: str): return self.wallet.get_request(key) - def get_invoice_as_dict(self, req: Invoice): - return self.wallet.export_request(req) + def get_invoice_as_dict(self, invoice: Invoice): + return self.wallet.export_request(invoice) @pyqtSlot(str, int) def updateRequest(self, key, status): From f6a46f3900944dfa4e5fd0edd589f808c7a191fb Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 3 Jun 2022 21:24:43 +0200 Subject: [PATCH 160/218] initial create invoice from user input --- electrum/gui/qml/components/OpenChannel.qml | 4 +- electrum/gui/qml/components/Send.qml | 78 +++++++++++++-------- electrum/gui/qml/qeinvoice.py | 65 ++++++++++++----- 3 files changed, 96 insertions(+), 51 deletions(-) diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml index 9bc0d9943..571895dfa 100644 --- a/electrum/gui/qml/components/OpenChannel.qml +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -84,7 +84,7 @@ Pane { BtcField { id: amount fiatfield: amountFiat - Layout.preferredWidth: parent.width /2 + Layout.preferredWidth: parent.width /3 onTextChanged: channelopener.amount = Config.unitsToSats(amount.text) enabled: !is_max.checked } @@ -111,7 +111,7 @@ Pane { id: amountFiat btcfield: amount visible: Daemon.fx.enabled - Layout.preferredWidth: parent.width /2 + Layout.preferredWidth: parent.width /3 enabled: !is_max.checked } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 02deca968..9e4ad1c4f 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -14,6 +14,7 @@ Pane { function clear() { recipient.text = '' amount.text = '' + message.text = '' } GridLayout { @@ -21,10 +22,10 @@ Pane { width: parent.width rowSpacing: constants.paddingSmall columnSpacing: constants.paddingSmall - columns: 4 + columns: 3 BalanceSummary { - Layout.columnSpan: 4 + Layout.columnSpan: 3 Layout.alignment: Qt.AlignHCenter } @@ -32,20 +33,22 @@ Pane { text: qsTr('Recipient') } - TextArea { - id: recipient - Layout.columnSpan: 2 + RowLayout { Layout.fillWidth: true - font.family: FixedFont - wrapMode: Text.Wrap - placeholderText: qsTr('Paste address or invoice') - onTextChanged: { - if (activeFocus) - invoice.recipient = text + Layout.columnSpan: 2 + + TextArea { + id: recipient + Layout.fillWidth: true + font.family: FixedFont + wrapMode: Text.Wrap + placeholderText: qsTr('Paste address or invoice') + onTextChanged: { + if (activeFocus) + invoice.recipient = text + } } - } - RowLayout { spacing: 0 ToolButton { icon.source: '../../icons/paste.png' @@ -75,15 +78,26 @@ Pane { id: amount fiatfield: amountFiat Layout.preferredWidth: parent.width /3 + onTextChanged: { + invoice.create_invoice(recipient.text, is_max.checked ? MAX : Config.unitsToSats(amount.text), message.text) + } } - Label { - text: Config.baseUnit - color: Material.accentColor + RowLayout { Layout.fillWidth: true - } - Item { width: 1; height: 1 } + Label { + text: Config.baseUnit + color: Material.accentColor + } + Switch { + id: is_max + text: qsTr('Max') + onCheckedChanged: { + invoice.create_invoice(recipient.text, is_max.checked ? MAX : Config.unitsToSats(amount.text), message.text) + } + } + } Item { width: 1; height: 1; visible: Daemon.fx.enabled } @@ -95,54 +109,56 @@ Pane { } Label { + Layout.fillWidth: true visible: Daemon.fx.enabled text: Daemon.fx.fiatCurrency color: Material.accentColor - Layout.fillWidth: true } - Item { visible: Daemon.fx.enabled ; height: 1; width: 1 } - Label { text: qsTr('Description') } TextField { id: message - font.family: FixedFont placeholderText: qsTr('Message') - Layout.columnSpan: 3 + Layout.columnSpan: 2 Layout.fillWidth: true + onTextChanged: { + invoice.create_invoice(recipient.text, is_max.checked ? MAX : Config.unitsToSats(amount.text), message.text) + } } RowLayout { - Layout.columnSpan: 4 + Layout.columnSpan: 3 Layout.alignment: Qt.AlignHCenter spacing: constants.paddingMedium Button { text: qsTr('Save') - enabled: invoice.invoiceType != Invoice.Invalid + enabled: invoice.canSave icon.source: '../../icons/save.png' onClicked: { - Daemon.currentWallet.create_invoice(recipient.text, amount.text, message.text) + invoice.save_invoice() + invoice.clear() + rootItem.clear() } } Button { text: qsTr('Pay now') - enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds + enabled: invoice.canPay icon.source: '../../icons/confirmed.png' onClicked: { - var f_amount = parseFloat(amount.text) - if (isNaN(f_amount)) - return + invoice.save_invoice() var dialog = confirmPaymentDialog.createObject(app, { 'address': recipient.text, 'satoshis': Config.unitsToSats(amount.text), 'message': message.text }) dialog.open() + invoice.clear() + rootItem.clear() } } } @@ -291,6 +307,8 @@ Pane { dialog.open() } } + onInvoiceCreateError: console.log(code + ' ' + message) + onInvoiceSaved: { console.log('invoice got saved') Daemon.currentWallet.invoiceModel.init_model() diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index ea25bc1f3..93f3ac8f5 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -5,13 +5,13 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS from electrum.logging import get_logger from electrum.i18n import _ -from electrum.keystore import bip39_is_checksum_valid from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, maybe_extract_bolt11_invoice) from electrum.invoices import Invoice from electrum.invoices import (PR_UNPAID,PR_EXPIRED,PR_UNKNOWN,PR_PAID,PR_INFLIGHT, PR_FAILED,PR_ROUTING,PR_UNCONFIRMED) from electrum.transaction import PartialTxOutput +from electrum import bitcoin from .qewallet import QEWallet from .qetypes import QEAmount @@ -44,6 +44,8 @@ class QEInvoice(QObject): _invoiceType = Type.Invalid _recipient = '' _effectiveInvoice = None + _canSave = False + _canPay = False invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal() @@ -52,6 +54,8 @@ class QEInvoice(QObject): validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) validationError = pyqtSignal([str,str], arguments=['code', 'message']) + invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) + def __init__(self, config, parent=None): super().__init__(parent) self.config = config @@ -99,10 +103,7 @@ class QEInvoice(QObject): self._amount = QEAmount() if not self._effectiveInvoice: return self._amount - sats = self._effectiveInvoice.get_amount_sat() - if not sats: - return self._amount - self._amount = QEAmount(amount_sat=sats) + self._amount = QEAmount(from_invoice=self._effectiveInvoice) return self._amount @pyqtProperty('quint64', notify=invoiceChanged) @@ -132,12 +133,33 @@ class QEInvoice(QObject): def address(self): return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' + @pyqtProperty(bool, notify=invoiceChanged) + def canSave(self): + return self._canSave + + @canSave.setter + def canSave(self, _canSave): + if self._canSave != _canSave: + self._canSave = _canSave + self.invoiceChanged.emit() + + @pyqtProperty(bool, notify=invoiceChanged) + def canPay(self): + return self._canPay + + @canPay.setter + def canPay(self, _canPay): + if self._canPay != _canPay: + self._canPay = _canPay + self.invoiceChanged.emit() + @pyqtSlot() def clear(self): self.recipient = '' - self.invoiceSetsAmount = False self.setInvoiceType(QEInvoice.Type.Invalid) self._bip21 = None + self._canSave = False + self._canPay = False self.invoiceChanged.emit() # don't parse the recipient string, but init qeinvoice from an invoice key @@ -155,6 +177,9 @@ class QEInvoice(QObject): self.setInvoiceType(QEInvoice.Type.LightningInvoice) else: self.setInvoiceType(QEInvoice.Type.OnchainInvoice) + # TODO check if exists? + self.canSave = True + self.canPay = True # TODO self.invoiceChanged.emit() self.statusChanged.emit() @@ -257,6 +282,7 @@ class QEInvoice(QObject): @pyqtSlot() def save_invoice(self): + self.canSave = False if not self._effectiveInvoice: return # TODO detect duplicate? @@ -265,9 +291,12 @@ class QEInvoice(QObject): @pyqtSlot(str, QEAmount, str) def create_invoice(self, address: str, amount: QEAmount, message: str): - # create onchain invoice from user entered fields + # create invoice from user entered fields # (any other type of invoice is created from parsing recipient) - self._logger.debug('saving invoice to %s' % address) + self._logger.debug('creating invoice to %s, amount=%s, message=%s' % (address, repr(amount), message)) + + self.clear() + if not address: self.invoiceCreateError.emit('fatal', _('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) return @@ -276,19 +305,17 @@ class QEInvoice(QObject): self.invoiceCreateError.emit('fatal', _('Invalid Bitcoin address')) return - if not self.amount: + if amount.isEmpty: self.invoiceCreateError.emit('fatal', _('Invalid amount')) return + inv_amt = '!' if amount.isMax else (amount.satsInt * 1000) # FIXME msat precision from UI? + try: + outputs = [PartialTxOutput.from_address_and_value(address, inv_amt)] + invoice = self._wallet.wallet.create_invoice(outputs=outputs, message=message, pr=None, URI=None) + except InvoiceError as e: + self.invoiceCreateError.emit('fatal', _('Error creating payment') + ':\n' + str(e)) + return - # - if self.is_max: - amount = '!' - else: - try: - amount = self.app.get_amount(self.amount) - except: - self.app.show_error(_('Invalid amount') + ':\n' + self.amount) - return - + self.set_effective_invoice(invoice) From 8f8a1fc8cf24431664748df39048dc356e0277ba Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 13 Jun 2022 19:00:31 +0200 Subject: [PATCH 161/218] wip --- electrum/gui/icons/save.png | Bin 0 -> 946 bytes electrum/gui/qml/components/History.qml | 27 +- electrum/gui/qml/components/InvoiceDialog.qml | 7 +- .../components/LightningPaymentDetails.qml | 261 ++++++++++++++++++ .../LightningPaymentProgressDialog.qml | 127 +++++++++ electrum/gui/qml/components/Send.qml | 20 +- electrum/gui/qml/components/TxDetails.qml | 69 ++--- electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qeconfig.py | 19 +- electrum/gui/qml/qeinvoice.py | 21 +- electrum/gui/qml/qeinvoicelistmodel.py | 2 +- electrum/gui/qml/qelnpaymentdetails.py | 114 ++++++++ electrum/gui/qml/qetransactionlistmodel.py | 35 ++- electrum/gui/qml/qetxdetails.py | 2 - electrum/gui/qml/qewallet.py | 44 ++- 15 files changed, 682 insertions(+), 68 deletions(-) create mode 100644 electrum/gui/icons/save.png create mode 100644 electrum/gui/qml/components/LightningPaymentDetails.qml create mode 100644 electrum/gui/qml/components/LightningPaymentProgressDialog.qml create mode 100644 electrum/gui/qml/qelnpaymentdetails.py diff --git a/electrum/gui/icons/save.png b/electrum/gui/icons/save.png new file mode 100644 index 0000000000000000000000000000000000000000..43859c85fd65d672ea1108f1b7d9922c627742e1 GIT binary patch literal 946 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSY)RhkE)4%caKYZ?lYt_f1s;*b z3=A$RAk65Hek24a$X?><>&pIsiIc-X*Ynb}Zww5~_MR?|Ar-gY-nI4@Nt9@N_}o>b zvp2_u`R}ooBYgjxTf@SnxV5#d>KOvFSLQ6ZG$SK%g@(o-&LB?%!!Hv$61n-09QhDo za4O3o?Xdag;vMVVnomFceQ);Mxp&`aFaoVa04b%ee{9=!PnxD0o4i}qGhFqnQquB| zj5p$@d}){|pXa&lxwT;7%`%r3Y4L`)oSwVS{BAkh&v9E>aO3?g40pnRXVwL)yzE@E z*>u+Ha?gJDh{vxxc0{*UH>Z^U_EY#BHFws#gRZyz*(2V$8S@pCnIC*pp4)%3kL}Iz z*^Jlz8vULVbX~Sn{Eywi#ec;G3->b}uGY&`dww=tIq9E$`{6y;lpa;hd@bYq=WEcM zS94dM(Erf0WY6IrGhS4myA{{j5uK`A)N=gM)Z)wEydv9~nO5Ynygy~3tJdI zi6N>+BVbp$Ea!rC?MzGzj13G93*wB3{dWg>@7|9m(O?ok;$I4@5!FG?e+En z)4%^(T9+Qs#achL#Pw3#-TOM3)uESqpE!4Wm~GdRW76~ZrIvZ;+s;{hb+^84R%*N7 zR?YD8!`jRFs=3#4{#8$s6SLaV@|_`+mqFK#rQxyzl7$!UFx>j{h0i3+lu(t zGOlOUG5E8eZI^E5LSKHS^Tyw@-f%8&oWs_H={YpN;^I1-W{Nmpxm)x0;++4DIr__l z-hT`byCnM{Dg1Hn9;+LI)nYf}o?Kn~t4XX_C+Yup(W5!#Q8s`4zCE9BYk4r~)qF*+ zs2Qa{{{NGy-n_5AUL|u)>gWH8ng2TWT9mC1ij!OSfeD@f*rsL^U8ISH?*u;6v=(ep|@w<{-2UW-W1Al8!)+|F}fac;0Pcm(2ly85}S Ib4q9e0FH8my#N3J literal 0 HcmV?d00001 diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml index 84d584982..5c1c5a8b9 100644 --- a/electrum/gui/qml/components/History.qml +++ b/electrum/gui/qml/components/History.qml @@ -67,11 +67,19 @@ Pane { Layout.preferredHeight: txinfo.height onClicked: { - var page = app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {'txid': model.txid}) - page.txDetailsChanged.connect(function() { - // update listmodel when details change - visualModel.model.update_tx_label(model.txid, page.label) - }) + if (model.lightning) { + var page = app.stack.push(Qt.resolvedUrl('LightningPaymentDetails.qml'), {'key': model.key}) + page.detailsChanged.connect(function() { + // update listmodel when details change + visualModel.model.update_tx_label(model.key, page.label) + }) + } else { + var page = app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {'txid': model.key}) + page.detailsChanged.connect(function() { + // update listmodel when details change + visualModel.model.update_tx_label(model.key, page.label) + }) + } } GridLayout { @@ -82,6 +90,7 @@ Pane { width: delegate.width - 2*constants.paddingSmall Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1} + Image { readonly property variant tx_icons : [ "../../../gui/icons/unconfirmed.png", @@ -97,7 +106,7 @@ Pane { Layout.preferredHeight: constants.iconSizeLarge Layout.alignment: Qt.AlignVCenter Layout.rowSpan: 2 - source: tx_icons[Math.min(6,model.confirmations)] + source: model.lightning ? "../../../gui/icons/lightning.png" : tx_icons[Math.min(6,model.confirmations)] } Label { @@ -118,7 +127,7 @@ Pane { color: model.incoming ? constants.colorCredit : constants.colorDebit function updateText() { - text = Config.formatSats(model.bc_value) + text = Config.formatSats(model.value) } Component.onCompleted: updateText() } @@ -137,9 +146,9 @@ Pane { if (!Daemon.fx.enabled) { text = '' } else if (Daemon.fx.historicRates) { - text = Daemon.fx.fiatValueHistoric(model.bc_value, model.timestamp) + ' ' + Daemon.fx.fiatCurrency + text = Daemon.fx.fiatValueHistoric(model.value, model.timestamp) + ' ' + Daemon.fx.fiatCurrency } else { - text = Daemon.fx.fiatValue(model.bc_value, false) + ' ' + Daemon.fx.fiatCurrency + text = Daemon.fx.fiatValue(model.value, false) + ' ' + Daemon.fx.fiatCurrency } } Component.onCompleted: updateText() diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index cb9f5a848..18697fe2f 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -126,7 +126,7 @@ Dialog { text: qsTr('Save') icon.source: '../../icons/save.png' visible: invoice_key == '' - enabled: invoice.invoiceType == Invoice.OnchainInvoice + enabled: invoice.canSave onClicked: { invoice.save_invoice() dialog.close() @@ -138,10 +138,13 @@ Dialog { icon.source: '../../icons/confirmed.png' enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds onClicked: { - invoice.save_invoice() + if (invoice_key == '') + invoice.save_invoice() dialog.close() if (invoice.invoiceType == Invoice.OnchainInvoice) { doPay() // only signal here + } else if (invoice.invoiceType == Invoice.LightningInvoice) { + doPay() // only signal here } } } diff --git a/electrum/gui/qml/components/LightningPaymentDetails.qml b/electrum/gui/qml/components/LightningPaymentDetails.qml new file mode 100644 index 000000000..63e423707 --- /dev/null +++ b/electrum/gui/qml/components/LightningPaymentDetails.qml @@ -0,0 +1,261 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string title: qsTr("Lightning payment details") + + property string key + + property alias label: lnpaymentdetails.label + + signal detailsChanged + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip: true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Status') + color: Material.accentColor + } + + Label { + text: lnpaymentdetails.status + } + + Label { + text: qsTr('Date') + color: Material.accentColor + } + + Label { + text: lnpaymentdetails.date + } + + Label { + text: lnpaymentdetails.amount.msatsInt > 0 + ? qsTr('Amount received') + : qsTr('Amount sent') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatMilliSats(lnpaymentdetails.amount) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + visible: lnpaymentdetails.amount.msatsInt < 0 + text: qsTr('Transaction fee') + color: Material.accentColor + } + + RowLayout { + visible: lnpaymentdetails.amount.msatsInt < 0 + Label { + text: Config.formatMilliSats(lnpaymentdetails.fee) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Label') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + id: labelContent + + property bool editmode: false + + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + visible: !labelContent.editmode + text: lnpaymentdetails.label + wrapMode: Text.Wrap + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + } + ToolButton { + visible: !labelContent.editmode + icon.source: '../../icons/pen.png' + icon.color: 'transparent' + onClicked: { + labelEdit.text = lnpaymentdetails.label + labelContent.editmode = true + labelEdit.focus = true + } + } + TextField { + id: labelEdit + visible: labelContent.editmode + text: lnpaymentdetails.label + font.pixelSize: constants.fontSizeLarge + Layout.fillWidth: true + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/confirmed.png' + icon.color: 'transparent' + onClicked: { + labelContent.editmode = false + lnpaymentdetails.set_label(labelEdit.text) + } + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/delete.png' + icon.color: 'transparent' + onClicked: labelContent.editmode = false + } + } + } + + Label { + text: qsTr('Payment hash') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: lnpaymentdetails.payment_hash + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Payment hash'), 'text': lnpaymentdetails.payment_hash }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Preimage') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: lnpaymentdetails.preimage + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Preimage'), 'text': lnpaymentdetails.preimage }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Lightning invoice') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + Layout.fillWidth: true + text: lnpaymentdetails.invoice + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + wrapMode: Text.Wrap + maximumLineCount: 3 + elide: Text.ElideRight + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: enabled ? 'transparent' : constants.mutedForeground + enabled: lnpaymentdetails.invoice != '' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Lightning Invoice'), 'text': lnpaymentdetails.invoice }) + dialog.open() + } + } + } + } + + + } + } + + LnPaymentDetails { + id: lnpaymentdetails + wallet: Daemon.currentWallet + key: root.key + onLabelChanged: root.detailsChanged() + } + + Component { + id: share + GenericShareDialog {} + } + +} diff --git a/electrum/gui/qml/components/LightningPaymentProgressDialog.qml b/electrum/gui/qml/components/LightningPaymentProgressDialog.qml new file mode 100644 index 000000000..b81b92201 --- /dev/null +++ b/electrum/gui/qml/components/LightningPaymentProgressDialog.qml @@ -0,0 +1,127 @@ +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 + + required property string invoice_key + + width: parent.width + height: parent.height + + title: qsTr('Paying Lightning Invoice...') + standardButtons: Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + Item { + id: s + state: '' + states: [ + State { + name: '' + }, + State { + name: 'success' + PropertyChanges { target: spinner; running: false } + PropertyChanges { target: helpText; text: qsTr('Paid!') } + PropertyChanges { target: dialog; standardButtons: Dialog.Ok } + PropertyChanges { target: icon; source: '../../icons/confirmed.png' } + }, + State { + name: 'failed' + PropertyChanges { target: spinner; running: false } + PropertyChanges { target: helpText; text: qsTr('Payment failed') } + PropertyChanges { target: dialog; standardButtons: Dialog.Ok } + PropertyChanges { target: errorText; visible: true } + PropertyChanges { target: icon; source: '../../icons/warning.png' } + } + ] + transitions: [ + Transition { + from: '' + to: 'success' + PropertyAnimation { target: helpText; properties: 'text'; duration: 0} + NumberAnimation { target: icon; properties: 'opacity'; from: 0; to: 1; duration: 200 } + NumberAnimation { target: icon; properties: 'scale'; from: 0; to: 1; duration: 500 + easing.type: Easing.OutBack + easing.overshoot: 10 + } + }, + Transition { + from: '' + to: 'failed' + PropertyAnimation { target: helpText; properties: 'text'; duration: 0} + NumberAnimation { target: icon; properties: 'opacity'; from: 0; to: 1; duration: 500 } + } + ] + } + + ColumnLayout { + id: content + anchors.centerIn: parent + + Item { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: constants.iconSizeXXLarge + Layout.preferredHeight: constants.iconSizeXXLarge + + BusyIndicator { + id: spinner + visible: s.state == '' + width: constants.iconSizeXXLarge + height: constants.iconSizeXXLarge + } + + Image { + id: icon + width: constants.iconSizeXXLarge + height: constants.iconSizeXXLarge + } + } + + Label { + id: helpText + text: qsTr('Paying...') + font.pixelSize: constants.fontSizeXXLarge + Layout.alignment: Qt.AlignHCenter + } + + Label { + id: errorText + font.pixelSize: constants.fontSizeLarge + Layout.alignment: Qt.AlignHCenter + } + } + + Connections { + target: Daemon.currentWallet + function onPaymentSucceeded(key) { + if (key != invoice_key) { + console.log('wrong invoice ' + key + ' != ' + invoice_key) + return + } + console.log('payment succeeded!') + s.state = 'success' + } + function onPaymentFailed(key, reason) { + if (key != invoice_key) { + console.log('wrong invoice ' + key + ' != ' + invoice_key) + return + } + console.log('payment failed: ' + reason) + s.state = 'failed' + errorText.text = reason + } + } +} diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 9e4ad1c4f..2a32c7325 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -161,6 +161,7 @@ Pane { rootItem.clear() } } + } } @@ -244,6 +245,11 @@ Pane { } } + Component { + id: lightningPaymentProgressDialog + LightningPaymentProgressDialog {} + } + Component { id: invoiceDialog InvoiceDialog { @@ -255,6 +261,17 @@ Pane { 'message': invoice.message }) dialog.open() + } else if (invoice.invoiceType == Invoice.LightningInvoice) { + console.log('About to pay lightning invoice') + if (invoice.key == '') { + console.log('No invoice key, aborting') + return + } + var dialog = lightningPaymentProgressDialog.createObject(rootItem, { + invoice_key: invoice.key + }) + dialog.open() + Daemon.currentWallet.pay_lightning_invoice(invoice.key) } } } @@ -263,8 +280,7 @@ Pane { Connections { target: Daemon.currentWallet function onInvoiceStatusChanged(key, status) { - // TODO: status from? - //Daemon.currentWallet.invoiceModel.updateInvoice(key, status) + Daemon.currentWallet.invoiceModel.updateInvoice(key, status) } } diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml index e1820a3a6..b0e8f8cab 100644 --- a/electrum/gui/qml/components/TxDetails.qml +++ b/electrum/gui/qml/components/TxDetails.qml @@ -18,7 +18,7 @@ Pane { property alias label: txdetails.label - signal txDetailsChanged + signal detailsChanged property QtObject menu: Menu { id: menu @@ -97,11 +97,13 @@ Pane { } Label { + visible: txdetails.amount.satsInt < 0 text: qsTr('Transaction fee') color: Material.accentColor } RowLayout { + visible: txdetails.amount.satsInt < 0 Label { text: Config.formatSats(txdetails.fee) } @@ -111,38 +113,6 @@ Pane { } } - Label { - text: qsTr('Transaction ID') - Layout.columnSpan: 2 - color: Material.accentColor - } - - TextHighlightPane { - Layout.columnSpan: 2 - Layout.fillWidth: true - padding: 0 - leftPadding: constants.paddingSmall - - RowLayout { - width: parent.width - Label { - text: root.txid - font.pixelSize: constants.fontSizeLarge - font.family: FixedFont - Layout.fillWidth: true - wrapMode: Text.Wrap - } - ToolButton { - icon.source: '../../icons/share.png' - icon.color: 'transparent' - onClicked: { - var dialog = share.createObject(root, { 'title': qsTr('Transaction ID'), 'text': root.txid }) - dialog.open() - } - } - } - } - Label { text: qsTr('Label') Layout.columnSpan: 2 @@ -203,6 +173,37 @@ Pane { } } + Label { + text: qsTr('Transaction ID') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: root.txid + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Transaction ID'), 'text': root.txid }) + dialog.open() + } + } + } + } Label { text: qsTr('Outputs') @@ -247,7 +248,7 @@ Pane { id: txdetails wallet: Daemon.currentWallet txid: root.txid - onLabelChanged: txDetailsChanged() + onLabelChanged: root.detailsChanged() } Component { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 9218de865..cd1728d59 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -24,6 +24,7 @@ from .qetypes import QEAmount from .qeaddressdetails import QEAddressDetails from .qetxdetails import QETxDetails from .qechannelopener import QEChannelOpener +from .qelnpaymentdetails import QELnPaymentDetails notification = None @@ -145,6 +146,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') + qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 76bca9e64..f8af476d6 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from decimal import Decimal from electrum.logging import get_logger -from electrum.util import DECIMAL_POINT_DEFAULT +from electrum.util import DECIMAL_POINT_DEFAULT, format_satoshis from .qetypes import QEAmount @@ -92,6 +92,23 @@ class QEConfig(QObject): else: return self.config.format_amount(satoshis) + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) + def formatMilliSats(self, amount, with_unit=False): + if isinstance(amount, QEAmount): + msats = amount.msatsInt + else: + return '---' + + s = format_satoshis(msats/1000, + decimal_point=self.decimal_point(), + precision=3) + return s + #if with_unit: + #return self.config.format_amount_and_units(msats) + #else: + #return self.config.format_amount(satoshis) + # TODO delegate all this to config.py/util.py def decimal_point(self): return self.config.get('decimal_point', DECIMAL_POINT_DEFAULT) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 93f3ac8f5..9dbd3daa5 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -46,6 +46,7 @@ class QEInvoice(QObject): _effectiveInvoice = None _canSave = False _canPay = False + _key = '' invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal() @@ -128,6 +129,17 @@ class QEInvoice(QObject): status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) return self._effectiveInvoice.get_status_str(status) + keyChanged = pyqtSignal() + @pyqtProperty(str, notify=keyChanged) + def key(self): + return self._key + + @key.setter + def key(self, key): + if self._key != key: + self._key = key + self.keyChanged.emit() + # single address only, TODO: n outputs @pyqtProperty(str, notify=invoiceChanged) def address(self): @@ -170,6 +182,7 @@ class QEInvoice(QObject): self._logger.debug(repr(invoice)) if invoice: self.set_effective_invoice(invoice) + self.key = key def set_effective_invoice(self, invoice: Invoice): self._effectiveInvoice = invoice @@ -264,9 +277,12 @@ class QEInvoice(QObject): else: self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') self.setValidOnchainInvoice(self._bip21['address']) - elif not self._wallet.wallet.lnworker.channels: - self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) + else: self.setValidLightningInvoice(lninvoice) + if not self._wallet.wallet.lnworker.channels: + self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) + else: + self.validationSuccess.emit() else: self._logger.debug('flow without LN but having bip21 uri') if 'amount' not in self._bip21: #TODO can we have amount-less invoices? @@ -286,6 +302,7 @@ class QEInvoice(QObject): if not self._effectiveInvoice: return # TODO detect duplicate? + self.key = self._wallet.wallet.get_key_for_outgoing_invoice(self._effectiveInvoice) self._wallet.wallet.save_invoice(self._effectiveInvoice) self.invoiceSaved.emit() diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index ea97ae9bf..c9815c775 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -51,7 +51,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): invoices = [] for invoice in self.get_invoice_list(): item = self.invoice_to_model(invoice) - self._logger.debug(str(item)) + #self._logger.debug(str(item)) invoices.append(item) self.clear() diff --git a/electrum/gui/qml/qelnpaymentdetails.py b/electrum/gui/qml/qelnpaymentdetails.py new file mode 100644 index 000000000..6644ad295 --- /dev/null +++ b/electrum/gui/qml/qelnpaymentdetails.py @@ -0,0 +1,114 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.util import format_time, bfh, format_time + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QELnPaymentDetails(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _key = None + _date = None + + detailsChanged = pyqtSignal() + + 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.walletChanged.emit() + + keyChanged = pyqtSignal() + @pyqtProperty(str, notify=keyChanged) + def key(self): + return self._key + + @key.setter + def key(self, key: str): + if self._key != key: + self._logger.debug('key set -> %s' % key) + self._key = key + self.keyChanged.emit() + self.update() + + labelChanged = pyqtSignal() + @pyqtProperty(str, notify=labelChanged) + def label(self): + return self._label + + @pyqtSlot(str) + def set_label(self, label: str): + if label != self._label: + self._wallet.wallet.set_label(self._key, label) + self._label = label + self.labelChanged.emit() + + @pyqtProperty(str, notify=detailsChanged) + def status(self): + return self._status + + @pyqtProperty(str, notify=detailsChanged) + def date(self): + return self._date + + @pyqtProperty(str, notify=detailsChanged) + def payment_hash(self): + return self._phash + + @pyqtProperty(str, notify=detailsChanged) + def preimage(self): + return self._preimage + + @pyqtProperty(str, notify=detailsChanged) + def invoice(self): + return self._invoice + + @pyqtProperty(QEAmount, notify=detailsChanged) + def amount(self): + return self._amount + + @pyqtProperty(QEAmount, notify=detailsChanged) + def fee(self): + return self._fee + + def update(self): + if self._wallet is None: + self._logger.error('wallet undefined') + return + + if self._key not in self._wallet.wallet.lnworker.payments: + self._logger.error('payment_hash not found') + return + + # TODO this is horribly inefficient. need a payment getter/query method + tx = self._wallet.wallet.lnworker.get_lightning_history()[bfh(self._key)] + self._logger.debug(str(tx)) + + self._fee = QEAmount() if not tx['fee_msat'] else QEAmount(amount_msat=tx['fee_msat']) + self._amount = QEAmount(amount_msat=tx['amount_msat']) + self._label = tx['label'] + self._date = format_time(tx['timestamp']) + self._status = 'settled' # TODO: other states? get_lightning_history is deciding the filter for us :( + self._phash = tx['payment_hash'] + self._preimage = tx['preimage'] + + invoice = (self._wallet.wallet.get_invoice(self._key) + or self._wallet.wallet.get_request(self._key)) + self._logger.debug(str(invoice)) + if invoice: + self._invoice = invoice.lightning_invoice or '' + else: + self._invoice = '' + + self.detailsChanged.emit() diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 8d8fb0e92..db1488c65 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -18,8 +18,8 @@ class QETransactionListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp', - 'incoming','bc_value','bc_balance','date','label','txpos_in_block','fee', - 'inputs','outputs','section') + 'incoming','value','balance','date','label','txpos_in_block','fee', + 'inputs','outputs','section','type','lightning','payment_hash','key') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -46,12 +46,23 @@ class QETransactionListModel(QAbstractListModel): self.endResetModel() def tx_to_model(self, tx): + #self._logger.debug(str(tx)) item = tx - for output in item['outputs']: - output['value'] = output['value'].value - item['bc_value'] = QEAmount(amount_sat=item['bc_value'].value) - item['bc_balance'] = QEAmount(amount_sat=item['bc_balance'].value) + item['key'] = item['txid'] if 'txid' in item else item['payment_hash'] + + if not 'lightning' in item: + item['lightning'] = False + + if item['lightning']: + item['value'] = QEAmount(amount_sat=item['value'].value, amount_msat=item['amount_msat']) + item['balance'] = QEAmount(amount_sat=item['balance'].value, amount_msat=item['amount_msat']) + if item['type'] == 'payment': + item['incoming'] = True if item['direction'] == 'received' else False + item['confirmations'] = 0 + else: + item['value'] = QEAmount(amount_sat=item['value'].value) + item['balance'] = QEAmount(amount_sat=item['balance'].value) # newly arriving txs have no (block) timestamp # TODO? @@ -90,9 +101,9 @@ class QETransactionListModel(QAbstractListModel): # initial model data def init_model(self): - history = self.wallet.get_detailed_history(show_addresses = True) + history = self.wallet.get_full_history() txs = [] - for tx in history['transactions']: + for key, tx in history.items(): txs.append(self.tx_to_model(tx)) self.clear() @@ -104,7 +115,7 @@ class QETransactionListModel(QAbstractListModel): def update_tx(self, txid, info): i = 0 for tx in self.tx_history: - if tx['txid'] == txid: + if 'txid' in tx and tx['txid'] == txid: tx['height'] = info.height tx['confirmations'] = info.conf tx['timestamp'] = info.timestamp @@ -116,10 +127,10 @@ class QETransactionListModel(QAbstractListModel): i = i + 1 @pyqtSlot(str, str) - def update_tx_label(self, txid, label): + def update_tx_label(self, key, label): i = 0 for tx in self.tx_history: - if tx['txid'] == txid: + if tx['key'] == key: tx['label'] = label index = self.index(i,0) self.dataChanged.emit(index, index, [self._ROLE_RMAP['label']]) @@ -131,7 +142,7 @@ class QETransactionListModel(QAbstractListModel): self._logger.debug('updating height to %d' % height) i = 0 for tx in self.tx_history: - if tx['height'] > 0: + if 'height' in tx and tx['height'] > 0: tx['confirmations'] = height - tx['height'] + 1 index = self.index(i,0) roles = [self._ROLE_RMAP['confirmations']] diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py index e8e427ce6..01df0c216 100644 --- a/electrum/gui/qml/qetxdetails.py +++ b/electrum/gui/qml/qetxdetails.py @@ -1,7 +1,5 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject -#from decimal import Decimal - from electrum.logging import get_logger from electrum.util import format_time diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 06df648a9..9b070bf7e 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,6 +1,8 @@ from typing import Optional, TYPE_CHECKING, Sequence, List, Union import queue import time +import asyncio +import threading from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer @@ -47,9 +49,11 @@ class QEWallet(QObject): requestStatusChanged = pyqtSignal([str,int], arguments=['key','status']) requestCreateSuccess = pyqtSignal() requestCreateError = pyqtSignal([str,str], arguments=['code','error']) - invoiceStatusChanged = pyqtSignal([str], arguments=['key']) + invoiceStatusChanged = pyqtSignal([str,int], arguments=['key','status']) invoiceCreateSuccess = pyqtSignal() invoiceCreateError = pyqtSignal([str,str], arguments=['code','error']) + paymentSucceeded = pyqtSignal([str], arguments=['key']) + paymentFailed = pyqtSignal([str,str], arguments=['key','reason']) _network_signal = pyqtSignal(str, object) @@ -95,6 +99,10 @@ class QEWallet(QObject): def on_network_qt(self, event, args=None): # note: we get events from all wallets! args are heterogenous so we can't # shortcut here + if event != 'status': + wallet = args[0] + if wallet == self.wallet: + self._logger.debug('event %s' % event) if event == 'status': self.isUptodateChanged.emit() elif event == 'request_status': @@ -105,8 +113,11 @@ class QEWallet(QObject): elif event == 'invoice_status': wallet, key = args if wallet == self.wallet: - self._logger.debug('invoice status %d for key %s' % (c, key)) - self.invoiceStatusChanged.emit(key) + self._logger.debug('invoice status update for key %s' % key) + # FIXME event doesn't pass the new status, so we need to retrieve + invoice = self.wallet.get_invoice(key) + status = self.wallet.get_invoice_status(invoice) + self.invoiceStatusChanged.emit(key, status) elif event == 'new_transaction': wallet, tx = args if wallet == self.wallet: @@ -129,6 +140,15 @@ class QEWallet(QObject): wallet, = args if wallet == self.wallet: self.balanceChanged.emit() + elif event == 'payment_succeeded': + wallet, key = args + if wallet == self.wallet: + self.paymentSucceeded.emit(key) + self._historyModel.init_model() # TODO: be less dramatic + elif event == 'payment_failed': + wallet, key, reason = args + if wallet == self.wallet: + self.paymentFailed.emit(key, reason) else: self._logger.debug('unhandled event: %s %s' % (event, str(args))) @@ -346,6 +366,24 @@ class QEWallet(QObject): return + @pyqtSlot(str) + def pay_lightning_invoice(self, invoice_key): + self._logger.debug('about to pay LN') + invoice = self.wallet.get_invoice(invoice_key) + assert(invoice) + assert(invoice.lightning_invoice) + amount_msat = invoice.get_amount_msat() + def pay_thread(): + try: + coro = self.wallet.lnworker.pay_invoice(invoice.lightning_invoice, amount_msat=amount_msat) + fut = asyncio.run_coroutine_threadsafe(coro, self.wallet.network.asyncio_loop) + fut.result() + except Exception as e: + self.userNotify(repr(e)) + #self.app.show_error(repr(e)) + #self.save_invoice(invoice) + threading.Thread(target=pay_thread).start() + def create_bitcoin_request(self, amount: int, message: str, expiration: int, ignore_gap: bool) -> Optional[str]: addr = self.wallet.get_unused_address() if addr is None: From 02ccd46fd5dc530b66636c9771fe51f29b837a63 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 14 Jun 2022 11:51:07 +0200 Subject: [PATCH 162/218] add numtx and address history model to addres details --- electrum/gui/qml/components/AddressDetails.qml | 9 +++++++++ electrum/gui/qml/qeaddressdetails.py | 18 +++++++++++++++++- electrum/gui/qml/qetransactionlistmodel.py | 7 +++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/AddressDetails.qml b/electrum/gui/qml/components/AddressDetails.qml index 79930fe41..34bbc348e 100644 --- a/electrum/gui/qml/components/AddressDetails.qml +++ b/electrum/gui/qml/components/AddressDetails.qml @@ -215,6 +215,15 @@ Pane { } } + Label { + text: qsTr('Transactions') + color: Material.accentColor + } + + Label { + text: addressdetails.numTx + } + Label { text: qsTr('Derivation path') color: Material.accentColor diff --git a/electrum/gui/qml/qeaddressdetails.py b/electrum/gui/qml/qeaddressdetails.py index 9bf20cb37..b832cd327 100644 --- a/electrum/gui/qml/qeaddressdetails.py +++ b/electrum/gui/qml/qeaddressdetails.py @@ -7,6 +7,7 @@ from electrum.util import DECIMAL_POINT_DEFAULT from .qewallet import QEWallet from .qetypes import QEAmount +from .qetransactionlistmodel import QETransactionListModel class QEAddressDetails(QObject): def __init__(self, parent=None): @@ -25,8 +26,9 @@ class QEAddressDetails(QObject): _pubkeys = None _privkey = None _derivationPath = None + _numtx = 0 - _txlistmodel = None + _historyModel = None detailsChanged = pyqtSignal() @@ -70,6 +72,10 @@ class QEAddressDetails(QObject): def derivationPath(self): return self._derivationPath + @pyqtProperty(int, notify=detailsChanged) + def numTx(self): + return self._numtx + frozenChanged = pyqtSignal() @pyqtProperty(bool, notify=frozenChanged) @@ -95,6 +101,14 @@ class QEAddressDetails(QObject): self._label = label self.labelChanged.emit() + historyModelChanged = pyqtSignal() + @pyqtProperty(QETransactionListModel, notify=historyModelChanged) + def historyModel(self): + if self._historyModel is None: + self._historyModel = QETransactionListModel(self._wallet.wallet, + onchain_domain=[self._address], include_lightning=False) + return self._historyModel + def update(self): if self._wallet is None: self._logger.error('wallet undefined') @@ -110,4 +124,6 @@ class QEAddressDetails(QObject): self._pubkeys = self._wallet.wallet.get_public_keys(self._address) self._derivationPath = self._wallet.wallet.get_address_path_str(self._address) self._derivationPath = self._derivationPath.replace('m', self._wallet.derivationPrefix) + self._numtx = self._wallet.wallet.get_address_history_len(self._address) + assert(self._numtx == self.historyModel.rowCount(0)) self.detailsChanged.emit() diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index db1488c65..6b18d6e3b 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -9,9 +9,11 @@ from electrum.util import Satoshis, TxMinedInfo from .qetypes import QEAmount class QETransactionListModel(QAbstractListModel): - def __init__(self, wallet, parent=None): + def __init__(self, wallet, parent=None, *, onchain_domain=None, include_lightning=True): super().__init__(parent) self.wallet = wallet + self.onchain_domain = onchain_domain + self.include_lightning = include_lightning self.init_model() _logger = get_logger(__name__) @@ -101,7 +103,8 @@ class QETransactionListModel(QAbstractListModel): # initial model data def init_model(self): - history = self.wallet.get_full_history() + history = self.wallet.get_full_history(onchain_domain=self.onchain_domain, + include_lightning=self.include_lightning) txs = [] for key, tx in history.items(): txs.append(self.tx_to_model(tx)) From 1d5a2736293f901da1bf0e1b9bb51d75f44b26a7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 14 Jun 2022 11:53:16 +0200 Subject: [PATCH 163/218] add address to InvoiceDialog for OnchainInvoice type --- electrum/gui/qml/components/InvoiceDialog.qml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 18697fe2f..8aab784c9 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -90,6 +90,19 @@ Dialog { } } + Label { + visible: invoice.invoiceType == Invoice.OnchainInvoice + text: qsTr('Address') + } + + Label { + visible: invoice.invoiceType == Invoice.OnchainInvoice + Layout.fillWidth: true + text: invoice.address + font.family: FixedFont + wrapMode: Text.Wrap + } + Label { text: qsTr('Status') } From 8819a7189cd817eb9d0582dd0758b9d54fa128c0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 14 Jun 2022 11:54:32 +0200 Subject: [PATCH 164/218] try tabbar at bottom --- .../gui/qml/components/WalletMainView.qml | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 110a49509..a7d339ae1 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -94,26 +94,6 @@ Item { anchors.fill: parent visible: Daemon.currentWallet != null - TabBar { - id: tabbar - Layout.fillWidth: true - currentIndex: swipeview.currentIndex - TabButton { - text: qsTr('Receive') - font.pixelSize: constants.fontSizeLarge - } - TabButton { - text: qsTr('History') - font.pixelSize: constants.fontSizeLarge - } - TabButton { - enabled: !Daemon.currentWallet.isWatchOnly - text: qsTr('Send') - font.pixelSize: constants.fontSizeLarge - } - Component.onCompleted: tabbar.setCurrentIndex(1) - } - SwipeView { id: swipeview @@ -154,6 +134,26 @@ Item { } + TabBar { + id: tabbar + Layout.fillWidth: true + currentIndex: swipeview.currentIndex + TabButton { + text: qsTr('Receive') + font.pixelSize: constants.fontSizeLarge + } + TabButton { + text: qsTr('History') + font.pixelSize: constants.fontSizeLarge + } + TabButton { + enabled: !Daemon.currentWallet.isWatchOnly + text: qsTr('Send') + font.pixelSize: constants.fontSizeLarge + } + Component.onCompleted: tabbar.setCurrentIndex(1) + } + } Connections { From e340f3fe9f0c35893ee6a16e057bd0b4a3d2149a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 14 Jun 2022 16:28:56 +0200 Subject: [PATCH 165/218] make QR always fit within 400px --- electrum/gui/qml/qeqr.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index d2e23f347..dabc5bcac 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -4,6 +4,7 @@ from PyQt5.QtQuick import QQuickImageProvider import asyncio import qrcode +import math from PIL import Image, ImageQt @@ -127,6 +128,11 @@ class QEQRImageProvider(QQuickImageProvider): self._logger.debug('QR requested for %s' % qstr) qr = qrcode.QRCode(version=1, box_size=6, border=2) qr.add_data(qstr) + + # calculate best box_size, aim for 400 px + modules = 17 + 4 * qr.best_fit() + qr.box_size = math.floor(400/(modules+2)) + qr.make(fit=True) pimg = qr.make_image(fill_color='black', back_color='white') From 098b384348a9740f02f4190047ca0b77d7464fd1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 14 Jun 2022 16:29:07 +0200 Subject: [PATCH 166/218] enable generating lightning request. currently very simple heuristics: if requested amount < lightningCanReceive then create a lightning request, else onchain --- electrum/gui/qml/components/Receive.qml | 11 +++++++++-- electrum/gui/qml/components/RequestDialog.qml | 2 ++ electrum/gui/qml/qeinvoicelistmodel.py | 3 ++- electrum/gui/qml/qewallet.py | 11 +++++++---- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 44cbf0f1c..4a8868dd1 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -199,8 +199,15 @@ Pane { } function createRequest(ignoreGaplimit = false) { - var a = Config.unitsToSats(amount.text) - Daemon.currentWallet.create_request(a, message.text, expires.currentValue, false, ignoreGaplimit) + var qamt = Config.unitsToSats(amount.text) + console.log('about to create req for ' + qamt.satsInt + ' sats') + if (qamt.satsInt > Daemon.currentWallet.lightningCanReceive) { + console.log('Creating OnChain request') + Daemon.currentWallet.create_request(qamt, message.text, expires.currentValue, false, ignoreGaplimit) + } else { + console.log('Creating Lightning request') + Daemon.currentWallet.create_request(qamt, message.text, expires.currentValue, true) + } } Connections { diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 437602077..caebe04a9 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -209,6 +209,8 @@ Dialog { if (!modelItem.is_lightning) { _bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp) qr.source = 'image://qrgen/' + _bip21uri + } else { + qr.source = 'image://qrgen/' + modelItem.lightning_invoice } } diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index c9815c775..0951c1291 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -19,7 +19,8 @@ class QEAbstractInvoiceListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('key', 'is_lightning', 'timestamp', 'date', 'message', 'amount', - 'status', 'status_str', 'address', 'expiration', 'type', 'onchain_fallback') + 'status', 'status_str', 'address', 'expiration', 'type', 'onchain_fallback', + 'lightning_invoice') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 9b070bf7e..62c185312 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -415,8 +415,8 @@ class QEWallet(QObject): ## TODO: check this flow. Only if alias is defined in config. OpenAlias? #pass ##self.sign_payment_request(addr) - self._requestModel.add_invoice(self.wallet.get_request(req_key)) - return addr + + return req_key, addr @pyqtSlot(QEAmount, 'QString', int) @pyqtSlot(QEAmount, 'QString', int, bool) @@ -428,9 +428,11 @@ class QEWallet(QObject): self.requestCreateError.emit('fatal',_("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.satsInt, message, expiration) + # TODO fallback address robustness + addr = self.wallet.get_unused_address() + key = self.wallet.create_request(amount.satsInt, message, expiration, addr, True) else: - key = self.create_bitcoin_request(amount.satsInt, message, expiration, ignore_gap) + key, addr = self.create_bitcoin_request(amount.satsInt, message, expiration, ignore_gap) if not key: return self.addressModel.init_model() @@ -439,6 +441,7 @@ class QEWallet(QObject): return assert key is not None + self._requestModel.add_invoice(self.wallet.get_request(key)) self.requestCreateSuccess.emit() @pyqtSlot('QString') From 486ef414af0e982a581861db2b94bfb16d69c0d3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 14 Jun 2022 16:30:49 +0200 Subject: [PATCH 167/218] implement enable lightning button --- electrum/gui/qml/components/Wallets.qml | 21 ++++++++++++++++++++- electrum/gui/qml/qewallet.py | 8 ++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 179a23df9..00abdf45b 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -48,7 +48,26 @@ Pane { Label { text: Daemon.currentWallet.isLightning } Label { text: 'has Seed'; color: Material.accentColor } - Label { text: Daemon.currentWallet.hasSeed; Layout.columnSpan: 3 } + Label { text: Daemon.currentWallet.hasSeed } + + RowLayout { + visible: !Daemon.currentWallet.isLightning && Daemon.currentWallet.canHaveLightning + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + + Button { + enabled: Daemon.currentWallet.canHaveLightning && !Daemon.currentWallet.isLightning + text: qsTr('Enable Lightning') + onClicked: Daemon.currentWallet.enableLightning() + } + } + + Item { + visible: Daemon.currentWallet.isLightning || !Daemon.currentWallet.canHaveLightning + Layout.columnSpan: 2 + Layout.preferredHeight: 1 + Layout.preferredWidth: 1 + } Label { Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor } diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 62c185312..c77ca47f7 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -243,6 +243,10 @@ class QEWallet(QObject): def isLightning(self): return bool(self.wallet.lnworker) + @pyqtProperty(bool, notify=dataChanged) + def canHaveLightning(self): + return self.wallet.can_have_lightning() + @pyqtProperty(bool, notify=dataChanged) def hasSeed(self): return self.wallet.has_seed() @@ -318,6 +322,10 @@ class QEWallet(QObject): self._lightningcanreceive = QEAmount(amount_sat=self.wallet.lnworker.num_sats_can_receive()) return self._lightningcanreceive + @pyqtSlot() + def enableLightning(self): + self.wallet.init_lightning(password=None) # TODO pass password if needed + self.isLightningChanged.emit() @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): From d3e88064d0a7a3ff0ee8152f1d645e1048d373f5 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 14 Jun 2022 17:24:50 +0200 Subject: [PATCH 168/218] Use screen size as upper bound for qr code size also fix some typing issues --- electrum/gui/qml/components/Receive.qml | 3 +-- electrum/gui/qml/qeapp.py | 4 +++- electrum/gui/qml/qeqr.py | 10 ++++++---- electrum/gui/qml/qewallet.py | 6 +++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml index 4a8868dd1..440f524c3 100644 --- a/electrum/gui/qml/components/Receive.qml +++ b/electrum/gui/qml/components/Receive.qml @@ -200,8 +200,7 @@ Pane { function createRequest(ignoreGaplimit = false) { var qamt = Config.unitsToSats(amount.text) - console.log('about to create req for ' + qamt.satsInt + ' sats') - if (qamt.satsInt > Daemon.currentWallet.lightningCanReceive) { + if (qamt.satsInt > Daemon.currentWallet.lightningCanReceive.satsInt) { console.log('Creating OnChain request') Daemon.currentWallet.create_request(qamt, message.text, expires.currentValue, false, ignoreGaplimit) } else { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index cd1728d59..6858982d5 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -153,7 +153,9 @@ class ElectrumQmlApplication(QGuiApplication): self.engine = QQmlApplicationEngine(parent=self) self.engine.addImportPath('./qml') - self.qr_ip = QEQRImageProvider() + screensize = self.primaryScreen().size() + + self.qr_ip = QEQRImageProvider((7/8)*min(screensize.width(), screensize.height())) self.engine.addImageProvider('qrgen', self.qr_ip) # add a monospace font as we can't rely on device having one diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index dabc5bcac..0d1626850 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -118,20 +118,22 @@ class QEQRParser(QObject): return result class QEQRImageProvider(QQuickImageProvider): - def __init__(self, parent=None): + def __init__(self, max_size, parent=None): super().__init__(QQuickImageProvider.Image) + self._max_size = max_size _logger = get_logger(__name__) @profiler def requestImage(self, qstr, size): self._logger.debug('QR requested for %s' % qstr) - qr = qrcode.QRCode(version=1, box_size=6, border=2) + qr = qrcode.QRCode(version=1, border=2) qr.add_data(qstr) - # calculate best box_size, aim for 400 px + # calculate best box_size + pixelsize = min(self._max_size, 400) modules = 17 + 4 * qr.best_fit() - qr.box_size = math.floor(400/(modules+2)) + qr.box_size = math.floor(pixelsize/(modules+2*2)) qr.make(fit=True) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index c77ca47f7..eb40a4078 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -305,21 +305,21 @@ class QEWallet(QObject): def lightningBalance(self): if not self.isLightning: return QEAmount() - self._lightningbalance = QEAmount(amount_sat=self.wallet.lnworker.get_balance()) + self._lightningbalance = QEAmount(amount_sat=int(self.wallet.lnworker.get_balance())) return self._lightningbalance @pyqtProperty(QEAmount, notify=balanceChanged) def lightningCanSend(self): if not self.isLightning: return QEAmount() - self._lightningcansend = QEAmount(amount_sat=self.wallet.lnworker.num_sats_can_send()) + self._lightningcansend = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_send())) return self._lightningcansend @pyqtProperty(QEAmount, notify=balanceChanged) def lightningCanReceive(self): if not self.isLightning: return QEAmount() - self._lightningcanreceive = QEAmount(amount_sat=self.wallet.lnworker.num_sats_can_receive()) + self._lightningcanreceive = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_receive())) return self._lightningcanreceive @pyqtSlot() From 5889c92e817f79b330107425bd5329c38e5ae4a1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 15 Jun 2022 11:39:46 +0200 Subject: [PATCH 169/218] improve network status display and states of items when no wallet loaded --- electrum/gui/qml/components/NetworkStats.qml | 10 ++++++---- electrum/gui/qml/components/Preferences.qml | 3 +-- electrum/gui/qml/components/WalletMainView.qml | 2 +- electrum/gui/qml/components/main.qml | 18 +++++------------- electrum/gui/qml/qenetwork.py | 8 ++++---- 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/electrum/gui/qml/components/NetworkStats.qml b/electrum/gui/qml/components/NetworkStats.qml index f23e2c4ca..abf42a7de 100644 --- a/electrum/gui/qml/components/NetworkStats.qml +++ b/electrum/gui/qml/components/NetworkStats.qml @@ -49,10 +49,12 @@ Pane { Layout.preferredWidth: constants.iconSizeSmall Layout.preferredHeight: constants.iconSizeSmall source: Network.status == 'connecting' || Network.status == 'disconnected' - ? '../../icons/status_disconnected.png' : - Daemon.currentWallet.isUptodate - ? '../../icons/status_connected.png' - : '../../icons/status_lagging.png' + ? '../../icons/status_disconnected.png' + : Network.status == 'connected' + ? Daemon.currentWallet && !Daemon.currentWallet.isUptodate + ? '../../icons/status_lagging.png' + : '../../icons/status_connected.png' + : '../../icons/status_connected.png' } Label { text: Network.status diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 702ee379c..e64ec97cc 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -108,14 +108,13 @@ Pane { Label { text: qsTr('Lightning Routing') - enabled: Daemon.currentWallet.isLightning } ComboBox { id: lnRoutingType valueRole: 'key' textRole: 'label' - enabled: Daemon.currentWallet.isLightning && false + enabled: Daemon.currentWallet != null && Daemon.currentWallet.isLightning && false model: ListModel { ListElement { key: 'gossip'; label: qsTr('Gossip') } ListElement { key: 'trampoline'; label: qsTr('Trampoline') } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index a7d339ae1..342221e6c 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -39,7 +39,7 @@ Item { icon.color: 'transparent' action: Action { text: qsTr('Channels'); - enabled: Daemon.currentWallet.isLightning + enabled: Daemon.currentWallet != null && Daemon.currentWallet.isLightning onTriggered: menu.openPage(Qt.resolvedUrl('Channels.qml')) icon.source: '../../icons/lightning.png' } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 5d438d24a..4e6a56723 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -53,16 +53,6 @@ ApplicationWindow visible: Network.isTestNet width: column.width height: column.height - MouseArea { - anchors.fill: parent - onClicked: { - var dialog = app.messageDialog.createObject(app, {'text': - 'Electrum is currently on ' + Network.networkName + '' - }) - dialog.open() - } - - } ColumnLayout { id: column @@ -96,9 +86,11 @@ ApplicationWindow Layout.preferredHeight: constants.iconSizeSmall source: Network.status == 'connecting' || Network.status == 'disconnected' ? '../../icons/status_disconnected.png' - : Daemon.currentWallet.isUptodate - ? '../../icons/status_connected.png' - : '../../icons/status_lagging.png' + : Network.status == 'connected' + ? Daemon.currentWallet && !Daemon.currentWallet.isUptodate + ? '../../icons/status_lagging.png' + : '../../icons/status_connected.png' + : '../../icons/status_connected.png' } Rectangle { diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index 3522185e9..73bad2856 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -60,11 +60,11 @@ class QENetwork(QObject): self._logger.debug('fee histogram updated') self.feeHistogramUpdated.emit() - @pyqtProperty(int,notify=heightChanged) + @pyqtProperty(int, notify=heightChanged) def height(self): return self._height - @pyqtProperty('QString',notify=defaultServerChanged) + @pyqtProperty('QString', notify=defaultServerChanged) def server(self): return str(self.network.get_parameters().server) @@ -79,7 +79,7 @@ class QENetwork(QObject): net_params = net_params._replace(server=server) self.network.run_from_another_thread(self.network.set_parameters(net_params)) - @pyqtProperty('QString',notify=statusChanged) + @pyqtProperty('QString', notify=statusChanged) def status(self): return self._status @@ -105,7 +105,7 @@ class QENetwork(QObject): self.network.run_from_another_thread(self.network.set_parameters(net_params)) self.proxyChanged.emit() - @pyqtProperty('QVariant',notify=feeHistogramUpdated) + @pyqtProperty('QVariant', notify=feeHistogramUpdated) def feeHistogram(self): return self.network.get_status_value('fee_histogram') From b3920f040821ae789752085fe391f128e4963944 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 15 Jun 2022 12:53:57 +0200 Subject: [PATCH 170/218] add frozen balance to balancesummary --- .../gui/qml/components/BalanceSummary.qml | 22 +++++++++++++------ electrum/gui/qml/qeaddressdetails.py | 1 + electrum/gui/qml/qewallet.py | 4 +++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml index fe6b77231..6d59814b1 100644 --- a/electrum/gui/qml/components/BalanceSummary.qml +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -9,19 +9,23 @@ Frame { font.pixelSize: constants.fontSizeMedium property string formattedBalance - property string formattedUnconfirmed property string formattedBalanceFiat + property string formattedUnconfirmed property string formattedUnconfirmedFiat + property string formattedFrozen + property string formattedFrozenFiat property string formattedLightningBalance property string formattedLightningBalanceFiat function setBalances() { root.formattedBalance = Config.formatSats(Daemon.currentWallet.confirmedBalance) root.formattedUnconfirmed = Config.formatSats(Daemon.currentWallet.unconfirmedBalance) + root.formattedFrozen = Config.formatSats(Daemon.currentWallet.frozenBalance) root.formattedLightningBalance = Config.formatSats(Daemon.currentWallet.lightningBalance) if (Daemon.fx.enabled) { root.formattedBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.confirmedBalance, false) root.formattedUnconfirmedFiat = Daemon.fx.fiatValue(Daemon.currentWallet.unconfirmedBalance, false) + root.formattedFrozenFiat = Daemon.fx.fiatValue(Daemon.currentWallet.frozenBalance, false) root.formattedLightningBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.lightningBalance, false) } } @@ -53,14 +57,16 @@ Frame { } } Label { - text: qsTr('Confirmed: ') + visible: Daemon.currentWallet.unconfirmedBalance.satsInt > 0 font.pixelSize: constants.fontSizeSmall + text: qsTr('Unconfirmed: ') } RowLayout { + visible: Daemon.currentWallet.unconfirmedBalance.satsInt > 0 Label { font.pixelSize: constants.fontSizeSmall font.family: FixedFont - text: formattedBalance + text: formattedUnconfirmed } Label { font.pixelSize: constants.fontSizeSmall @@ -70,19 +76,21 @@ Frame { Label { font.pixelSize: constants.fontSizeSmall text: Daemon.fx.enabled - ? '(' + root.formattedBalanceFiat + ' ' + Daemon.fx.fiatCurrency + ')' + ? '(' + root.formattedUnconfirmedFiat + ' ' + Daemon.fx.fiatCurrency + ')' : '' } } Label { + visible: Daemon.currentWallet.frozenBalance.satsInt > 0 font.pixelSize: constants.fontSizeSmall - text: qsTr('Unconfirmed: ') + text: qsTr('Frozen: ') } RowLayout { + visible: Daemon.currentWallet.frozenBalance.satsInt > 0 Label { font.pixelSize: constants.fontSizeSmall font.family: FixedFont - text: formattedUnconfirmed + text: root.formattedFrozen } Label { font.pixelSize: constants.fontSizeSmall @@ -92,7 +100,7 @@ Frame { Label { font.pixelSize: constants.fontSizeSmall text: Daemon.fx.enabled - ? '(' + root.formattedUnconfirmedFiat + ' ' + Daemon.fx.fiatCurrency + ')' + ? '(' + root.formattedFrozenFiat + ' ' + Daemon.fx.fiatCurrency + ')' : '' } } diff --git a/electrum/gui/qml/qeaddressdetails.py b/electrum/gui/qml/qeaddressdetails.py index b832cd327..719ad0831 100644 --- a/electrum/gui/qml/qeaddressdetails.py +++ b/electrum/gui/qml/qeaddressdetails.py @@ -93,6 +93,7 @@ class QEAddressDetails(QObject): self._wallet.wallet.set_frozen_state_of_addresses([self._address], freeze=freeze) self._frozen = freeze self.frozenChanged.emit() + self._wallet.balanceChanged.emit() @pyqtSlot(str) def set_label(self, label: str): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index eb40a4078..da2312bf4 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -286,7 +286,9 @@ class QEWallet(QObject): @pyqtProperty(QEAmount, notify=balanceChanged) def frozenBalance(self): - self._frozenbalance = QEAmount(amount_sat=self.wallet.get_frozen_balance()) + c, u, x = self.wallet.get_frozen_balance() + self._logger.info('frozen balance: ' + str(c) + ' ' + str(u) + ' ' + str(x) + ' ') + self._frozenbalance = QEAmount(amount_sat=c+x) return self._frozenbalance @pyqtProperty(QEAmount, notify=balanceChanged) From 4e9802268658467ec1b10aaf400952c8cad67300 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 15 Jun 2022 13:30:28 +0200 Subject: [PATCH 171/218] fix some issues with QEAmount use - always cast amount_sat and amount_msat to int within QEAmount to avoid conversion issues on the Qt/python boundary - lightningBalance/lightningCanSend/lightningCanReceive were returning a floating QEAMount instance, leading to a crash --- electrum/gui/qml/qetypes.py | 8 ++++---- electrum/gui/qml/qewallet.py | 15 +++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py index 641f9e03d..b2390f052 100644 --- a/electrum/gui/qml/qetypes.py +++ b/electrum/gui/qml/qetypes.py @@ -17,16 +17,16 @@ class QEAmount(QObject): def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, from_invoice = None, parent=None): super().__init__(parent) - self._amount_sat = amount_sat - self._amount_msat = amount_msat + self._amount_sat = int(amount_sat) if amount_sat is not None else None + self._amount_msat = int(amount_msat) if amount_msat is not None else None self._is_max = is_max if from_invoice: inv_amt = from_invoice.get_amount_msat() if inv_amt == '!': self._is_max = True elif inv_amt is not None: - self._amount_msat = inv_amt - self._amount_sat = from_invoice.get_amount_sat() + self._amount_msat = int(inv_amt) + self._amount_sat = int(from_invoice.get_amount_sat()) valueChanged = pyqtSignal() diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index da2312bf4..5580ae66e 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -306,22 +306,25 @@ class QEWallet(QObject): @pyqtProperty(QEAmount, notify=balanceChanged) def lightningBalance(self): if not self.isLightning: - return QEAmount() - self._lightningbalance = QEAmount(amount_sat=int(self.wallet.lnworker.get_balance())) + self._lightningbalance = QEAmount() + else: + self._lightningbalance = QEAmount(amount_sat=int(self.wallet.lnworker.get_balance())) return self._lightningbalance @pyqtProperty(QEAmount, notify=balanceChanged) def lightningCanSend(self): if not self.isLightning: - return QEAmount() - self._lightningcansend = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_send())) + self._lightningcansend = QEAmount() + else: + self._lightningcansend = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_send())) return self._lightningcansend @pyqtProperty(QEAmount, notify=balanceChanged) def lightningCanReceive(self): if not self.isLightning: - return QEAmount() - self._lightningcanreceive = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_receive())) + self._lightningcanreceive = QEAmount() + else: + self._lightningcanreceive = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_receive())) return self._lightningcanreceive @pyqtSlot() From f8dd41114802528d70c85459a4fab776cf1454c3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 15 Jun 2022 13:33:19 +0200 Subject: [PATCH 172/218] styling --- electrum/gui/qml/components/InvoiceDialog.qml | 46 ++++--- electrum/gui/qml/components/RequestDialog.qml | 1 + electrum/gui/qml/components/TxDetails.qml | 5 +- .../controls/GenericShareDialog.qml | 123 +++++++++--------- .../components/controls/TextHighlightPane.qml | 2 + 5 files changed, 102 insertions(+), 75 deletions(-) diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 8aab784c9..bd097f69c 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -44,24 +44,25 @@ Dialog { text: qsTr('Type') } - Label { - text: invoice.invoiceType == Invoice.OnchainInvoice - ? qsTr('On-chain invoice') - : invoice.invoiceType == Invoice.LightningInvoice - ? qsTr('Lightning invoice') - : '' + RowLayout { Layout.fillWidth: true - } - - Label { - text: qsTr('Description') - } + Image { + //Layout.rowSpan: 2 + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + source: invoice.invoiceType == Invoice.LightningInvoice + ? "../../icons/lightning.png" + : "../../icons/bitcoin.png" + } - Label { - text: invoice.message - Layout.fillWidth: true - wrapMode: Text.Wrap - elide: Text.ElideRight + Label { + text: invoice.invoiceType == Invoice.OnchainInvoice + ? qsTr('On chain') + : invoice.invoiceType == Invoice.LightningInvoice + ? qsTr('Lightning') + : '' + Layout.fillWidth: true + } } Label { @@ -71,6 +72,8 @@ Dialog { RowLayout { Layout.fillWidth: true Label { + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont font.bold: true text: Config.formatSats(invoice.amount, false) } @@ -90,6 +93,17 @@ Dialog { } } + Label { + text: qsTr('Description') + } + + Label { + text: invoice.message + Layout.fillWidth: true + wrapMode: Text.Wrap + elide: Text.ElideRight + } + Label { visible: invoice.invoiceType == Invoice.OnchainInvoice text: qsTr('Address') diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index caebe04a9..6a4b79e7f 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -142,6 +142,7 @@ Dialog { text: Config.formatSats(modelItem.amount) font.family: FixedFont font.pixelSize: constants.fontSizeLarge + font.bold: true } Label { visible: modelItem.amount.satsInt != 0 diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml index b0e8f8cab..d9788a7ec 100644 --- a/electrum/gui/qml/components/TxDetails.qml +++ b/electrum/gui/qml/components/TxDetails.qml @@ -89,6 +89,7 @@ Pane { RowLayout { Label { text: Config.formatSats(txdetails.amount) + font.family: FixedFont } Label { text: Config.baseUnit @@ -106,6 +107,7 @@ Pane { visible: txdetails.amount.satsInt < 0 Label { text: Config.formatSats(txdetails.fee) + font.family: FixedFont } Label { text: Config.baseUnit @@ -230,7 +232,8 @@ Pane { } Label { text: Config.formatSats(modelData.value) - font.pixelSize: constants.fontSizeLarge + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont } Label { text: Config.baseUnit diff --git a/electrum/gui/qml/components/controls/GenericShareDialog.qml b/electrum/gui/qml/components/controls/GenericShareDialog.qml index 392ebfcd6..1e008b7c6 100644 --- a/electrum/gui/qml/components/controls/GenericShareDialog.qml +++ b/electrum/gui/qml/components/controls/GenericShareDialog.qml @@ -34,73 +34,80 @@ Dialog { } } - ColumnLayout { - id: rootLayout - width: parent.width - spacing: constants.paddingMedium + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip:true + interactive: height < contentHeight - Rectangle { - height: 1 - Layout.fillWidth: true - color: Material.accentColor - } - - Image { - id: qr - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: constants.paddingSmall - Layout.bottomMargin: constants.paddingSmall + ColumnLayout { + id: rootLayout + width: parent.width + spacing: constants.paddingMedium Rectangle { - property int size: 57 // should be qr pixel multiple - color: 'white' - x: (parent.width - size) / 2 - y: (parent.height - size) / 2 - width: size - height: size - - Image { - source: '../../../icons/electrum.png' - x: 1 - y: 1 - width: parent.width - 2 - height: parent.height - 2 - scale: 0.9 - } + height: 1 + Layout.fillWidth: true + color: Material.accentColor } - } - Rectangle { - height: 1 - Layout.fillWidth: true - color: Material.accentColor - } + Image { + id: qr + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingSmall + Layout.bottomMargin: constants.paddingSmall - TextHighlightPane { - Layout.fillWidth: true - Label { - width: parent.width - text: dialog.text - wrapMode: Text.Wrap - font.pixelSize: constants.fontSizeLarge - font.family: FixedFont + Rectangle { + property int size: 57 // should be qr pixel multiple + color: 'white' + x: (parent.width - size) / 2 + y: (parent.height - size) / 2 + width: size + height: size + + Image { + source: '../../../icons/electrum.png' + x: 1 + y: 1 + width: parent.width - 2 + height: parent.height - 2 + scale: 0.9 + } + } } - } - RowLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - Button { - text: qsTr('Copy') - icon.source: '../../../icons/copy_bw.png' - onClicked: AppController.textToClipboard(dialog.text) + Rectangle { + height: 1 + Layout.fillWidth: true + color: Material.accentColor } - Button { - //enabled: false - text: qsTr('Share') - icon.source: '../../../icons/share.png' - onClicked: { - AppController.doShare(dialog.text, dialog.title) + + TextHighlightPane { + Layout.fillWidth: true + Label { + width: parent.width + text: dialog.text + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + } + } + + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + Button { + text: qsTr('Copy') + icon.source: '../../../icons/copy_bw.png' + onClicked: AppController.textToClipboard(dialog.text) + } + Button { + //enabled: false + text: qsTr('Share') + icon.source: '../../../icons/share.png' + onClicked: { + AppController.doShare(dialog.text, dialog.title) + } } } } diff --git a/electrum/gui/qml/components/controls/TextHighlightPane.qml b/electrum/gui/qml/components/controls/TextHighlightPane.qml index 5ac2e5331..7e38b327f 100644 --- a/electrum/gui/qml/components/controls/TextHighlightPane.qml +++ b/electrum/gui/qml/components/controls/TextHighlightPane.qml @@ -4,6 +4,8 @@ import QtQuick.Controls 2.0 import QtQuick.Controls.Material 2.0 Pane { + topPadding: constants.paddingSmall + bottomPadding: constants.paddingSmall background: Rectangle { color: Qt.lighter(Material.background, 1.15) radius: constants.paddingSmall From 0b8de89e66a5aaffa31cdb0deab117920c2da5c1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 15 Jun 2022 18:19:04 +0200 Subject: [PATCH 173/218] add hamburger menu to Wallets page --- electrum/gui/qml/components/Wallets.qml | 148 +++++++++++++++++++----- 1 file changed, 116 insertions(+), 32 deletions(-) diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 00abdf45b..9cc941bf3 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -1,6 +1,6 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.0 +import QtQuick.Controls 2.3 import QtQuick.Controls.Material 2.0 import org.electrum 1.0 @@ -12,6 +12,100 @@ Pane { property string title: qsTr('Wallets') + function createWallet() { + var dialog = app.newWalletWizard.createObject(rootItem) + dialog.open() + dialog.walletCreated.connect(function() { + Daemon.availableWallets.reload() + // and load the new wallet + Daemon.load_wallet(dialog.path, dialog.wizard_data['password']) + }) + } + + function enableLightning() { + var dialog = app.messageDialog.createObject(rootItem, + {'text': qsTr('Enable Lightning for this wallet?'), 'yesno': true}) + dialog.yesClicked.connect(function() { + Daemon.currentWallet.enableLightning() + }) + dialog.open() + } + + function deleteWallet() { + var dialog = app.messageDialog.createObject(rootItem, + {'text': qsTr('Really delete this wallet?'), 'yesno': true}) + dialog.yesClicked.connect(function() { + Daemon.currentWallet.deleteWallet() + }) + dialog.open() + } + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Create Wallet'); + onTriggered: rootItem.createWallet() + icon.source: '../../icons/wallet.png' + } + } + Component { + id: changePasswordComp + MenuItem { + icon.color: 'transparent' + enabled: false + action: Action { + text: qsTr('Change Password'); + onTriggered: rootItem.changePassword() + icon.source: '../../icons/lock.png' + } + } + } + Component { + id: deleteWalletComp + MenuItem { + icon.color: 'transparent' + enabled: false + action: Action { + text: qsTr('Delete Wallet'); + onTriggered: rootItem.deleteWallet() + icon.source: '../../icons/delete.png' + } + } + } + + Component { + id: enableLightningComp + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Enable Lightning'); + onTriggered: rootItem.enableLightning() + enabled: Daemon.currentWallet != null && Daemon.currentWallet.canHaveLightning && !Daemon.currentWallet.isLightning + icon.source: '../../icons/lightning.png' + } + } + } + + Component { + id: sepComp + MenuSeparator {} + } + + // add items dynamically, if using visible: false property the menu item isn't removed but empty + Component.onCompleted: { + if (Daemon.currentWallet != null) { + menu.insertItem(0, sepComp.createObject(menu)) + if (Daemon.currentWallet.canHaveLightning && !Daemon.currentWallet.isLightning) { + menu.insertItem(0, enableLightningComp.createObject(menu)) + } + menu.insertItem(0, deleteWalletComp.createObject(menu)) + menu.insertItem(0, changePasswordComp.createObject(menu)) + } + } + } + ColumnLayout { id: layout width: parent.width @@ -19,12 +113,13 @@ Pane { GridLayout { id: detailsLayout + visible: Daemon.currentWallet != null Layout.preferredWidth: parent.width columns: 4 Label { text: 'Wallet'; Layout.columnSpan: 2; color: Material.accentColor } - Label { text: Daemon.currentWallet.name; Layout.columnSpan: 2 } + Label { text: Daemon.currentWallet.name; font.bold: true /*pixelSize: constants.fontSizeLarge*/; Layout.columnSpan: 2 } Label { text: 'derivation prefix (BIP32)'; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } Label { text: Daemon.currentWallet.derivationPrefix; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } @@ -48,26 +143,7 @@ Pane { Label { text: Daemon.currentWallet.isLightning } Label { text: 'has Seed'; color: Material.accentColor } - Label { text: Daemon.currentWallet.hasSeed } - - RowLayout { - visible: !Daemon.currentWallet.isLightning && Daemon.currentWallet.canHaveLightning - Layout.columnSpan: 2 - Layout.alignment: Qt.AlignHCenter - - Button { - enabled: Daemon.currentWallet.canHaveLightning && !Daemon.currentWallet.isLightning - text: qsTr('Enable Lightning') - onClicked: Daemon.currentWallet.enableLightning() - } - } - - Item { - visible: Daemon.currentWallet.isLightning || !Daemon.currentWallet.canHaveLightning - Layout.columnSpan: 2 - Layout.preferredHeight: 1 - Layout.preferredWidth: 1 - } + Label { text: Daemon.currentWallet.hasSeed; Layout.columnSpan: 3 } Label { Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor } @@ -83,6 +159,7 @@ Pane { text: Daemon.currentWallet.masterPubkey wrapMode: Text.Wrap Layout.fillWidth: true + font.family: FixedFont font.pixelSize: constants.fontSizeMedium } ToolButton { @@ -100,10 +177,25 @@ Pane { } } - Item { width: 1; height: 1 } + ColumnLayout { + visible: Daemon.currentWallet == null + + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: constants.paddingXXLarge + Layout.topMargin: constants.paddingXXLarge + spacing: 2*constants.paddingXLarge + + Label { + text: qsTr('No wallet loaded') + font.pixelSize: constants.fontSizeXXLarge + Layout.alignment: Qt.AlignHCenter + } + + } Frame { id: detailsFrame + Layout.topMargin: constants.paddingXLarge Layout.preferredWidth: parent.width Layout.fillHeight: true verticalPadding: 0 @@ -180,15 +272,7 @@ Pane { Button { Layout.alignment: Qt.AlignHCenter text: 'Create Wallet' - onClicked: { - var dialog = app.newWalletWizard.createObject(rootItem) - dialog.open() - dialog.walletCreated.connect(function() { - Daemon.availableWallets.reload() - // and load the new wallet - Daemon.load_wallet(dialog.path, dialog.wizard_data['password']) - }) - } + onClicked: rootItem.createWallet() } } From 7cb8c347b50ad54018bebb8f4ef41d84f273c1fb Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 15 Jun 2022 22:41:33 +0200 Subject: [PATCH 174/218] Add @auth_protect decorator. This guards function calls by storing the function, args and kwargs into an added attribute '__auth_fcall' on the object using the decorator, then emits a signal that can be handled by the UI. The UI can signal auth-success or auth-failure back to the object by calling either the authProceed() slot or the authCancel slot. The object utilizing this decorator MUST inherit/mixin the AuthMixin class, which provides the above two slots, and handling of state. The decorator also accepts a 'reject' parameter, containing the name of a parameterless function on the object, which is called when authentication has failed/is cancelled. --- electrum/gui/qml/auth.py | 58 +++++++++++++++++++ .../LightningPaymentProgressDialog.qml | 3 + electrum/gui/qml/components/main.qml | 14 +++++ electrum/gui/qml/qewallet.py | 16 +++-- 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 electrum/gui/qml/auth.py diff --git a/electrum/gui/qml/auth.py b/electrum/gui/qml/auth.py new file mode 100644 index 000000000..e55a0c036 --- /dev/null +++ b/electrum/gui/qml/auth.py @@ -0,0 +1,58 @@ +from functools import wraps, partial + +from PyQt5.QtCore import pyqtSignal, pyqtSlot + +from electrum.logging import get_logger + +def auth_protect(func=None, reject=None): + if func is None: + return partial(auth_protect, reject=reject) + + @wraps(func) + def wrapper(self, *args, **kwargs): + self._logger.debug(str(self)) + if hasattr(self, '__auth_fcall'): + self._logger.debug('object already has a pending authed function call') + raise Exception('object already has a pending authed function call') + setattr(self, '__auth_fcall', (func,args,kwargs,reject)) + getattr(self, 'authRequired').emit() + + return wrapper + +class AuthMixin: + _auth_logger = get_logger(__name__) + + authRequired = pyqtSignal() + + @pyqtSlot() + def authProceed(self): + self._auth_logger.debug('Proceeding with authed fn()') + try: + self._auth_logger.debug(str(getattr(self, '__auth_fcall'))) + (func,args,kwargs,reject) = getattr(self, '__auth_fcall') + r = func(self, *args, **kwargs) + return r + except Exception as e: + self._auth_logger.error('Error executing wrapped fn(): %s' % repr(e)) + raise e + finally: + delattr(self,'__auth_fcall') + + @pyqtSlot() + def authCancel(self): + self._auth_logger.debug('Cancelling authed fn()') + if not hasattr(self, '__auth_fcall'): + return + + try: + (func,args,kwargs,reject) = getattr(self, '__auth_fcall') + if reject is not None: + if hasattr(self, reject): + getattr(self, reject)() + else: + self._auth_logger.error('Reject method \'%s\' not defined' % reject) + except Exception as e: + self._auth_logger.error('Error executing reject function \'%s\': %s' % (reject, repr(e))) + raise e + finally: + delattr(self, '__auth_fcall') diff --git a/electrum/gui/qml/components/LightningPaymentProgressDialog.qml b/electrum/gui/qml/components/LightningPaymentProgressDialog.qml index b81b92201..3dc4191fe 100644 --- a/electrum/gui/qml/components/LightningPaymentProgressDialog.qml +++ b/electrum/gui/qml/components/LightningPaymentProgressDialog.qml @@ -123,5 +123,8 @@ Dialog { s.state = 'failed' errorText.text = reason } + function onPaymentAuthRejected() { + dialog.close() + } } } diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 4e6a56723..775b735a4 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -221,4 +221,18 @@ ApplicationWindow notificationPopup.show(message) } } + + Connections { + target: Daemon.currentWallet + function onAuthRequired() { + var dialog = app.messageDialog.createObject(app, {'text': 'Auth placeholder', 'yesno': true}) + dialog.yesClicked.connect(function() { + Daemon.currentWallet.authProceed() + }) + dialog.noClicked.connect(function() { + Daemon.currentWallet.authCancel() + }) + dialog.open() + } + } } diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 5580ae66e..98467a73b 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -21,8 +21,9 @@ from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel from .qechannellistmodel import QEChannelListModel from .qetypes import QEAmount +from .auth import AuthMixin, auth_protect -class QEWallet(QObject): +class QEWallet(AuthMixin, QObject): __instances = [] # this factory method should be used to instantiate QEWallet @@ -90,7 +91,7 @@ class QEWallet(QObject): return self.wallet.is_up_to_date() def on_network(self, event, *args): - if event == 'new_transaction': + if event in ['new_transaction', 'payment_succeeded']: # Handle in GUI thread (_network_signal -> on_network_qt) self._network_signal.emit(event, args) else: @@ -356,6 +357,7 @@ class QEWallet(QObject): tx.set_rbf(use_rbf) self.sign_and_broadcast(tx) + @auth_protect def sign_and_broadcast(self, tx): def cb(result): self._logger.info('signing was succesful? %s' % str(result)) @@ -379,13 +381,20 @@ class QEWallet(QObject): return + paymentAuthRejected = pyqtSignal() + def ln_auth_rejected(self): + self.paymentAuthRejected.emit() + @pyqtSlot(str) + @auth_protect(reject='ln_auth_rejected') def pay_lightning_invoice(self, invoice_key): self._logger.debug('about to pay LN') invoice = self.wallet.get_invoice(invoice_key) assert(invoice) assert(invoice.lightning_invoice) + amount_msat = invoice.get_amount_msat() + def pay_thread(): try: coro = self.wallet.lnworker.pay_invoice(invoice.lightning_invoice, amount_msat=amount_msat) @@ -393,8 +402,7 @@ class QEWallet(QObject): fut.result() except Exception as e: self.userNotify(repr(e)) - #self.app.show_error(repr(e)) - #self.save_invoice(invoice) + threading.Thread(target=pay_thread).start() def create_bitcoin_request(self, amount: int, message: str, expiration: int, ignore_gap: bool) -> Optional[str]: From c505de2fe051db1c8428ed37ae2672af5a4a8645 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 20 Jun 2022 16:50:31 +0200 Subject: [PATCH 175/218] fixes after rebase persist_lnwatcher --- electrum/gui/qml/qeaddressdetails.py | 2 +- electrum/gui/qml/qeaddresslistmodel.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/qeaddressdetails.py b/electrum/gui/qml/qeaddressdetails.py index 719ad0831..67e67fdd9 100644 --- a/electrum/gui/qml/qeaddressdetails.py +++ b/electrum/gui/qml/qeaddressdetails.py @@ -125,6 +125,6 @@ class QEAddressDetails(QObject): self._pubkeys = self._wallet.wallet.get_public_keys(self._address) self._derivationPath = self._wallet.wallet.get_address_path_str(self._address) self._derivationPath = self._derivationPath.replace('m', self._wallet.derivationPrefix) - self._numtx = self._wallet.wallet.get_address_history_len(self._address) + self._numtx = self._wallet.wallet.adb.get_address_history_len(self._address) assert(self._numtx == self.historyModel.rowCount(0)) self.detailsChanged.emit() diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index e0d9dceab..094674f34 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -47,7 +47,7 @@ class QEAddressListModel(QAbstractListModel): def addr_to_model(self, address): item = {} item['address'] = address - item['numtx'] = self.wallet.get_address_history_len(address) + item['numtx'] = self.wallet.adb.get_address_history_len(address) item['label'] = self.wallet.get_label(address) c, u, x = self.wallet.get_addr_balance(address) item['balance'] = QEAmount(amount_sat=c + u + x) From 0d0aed1aaa5892599e6886abe4fdbfd699db6e46 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 20 Jun 2022 17:28:15 +0200 Subject: [PATCH 176/218] fixes after rebase a3faf85e3cce83f90d5a3af8e0fd48f06ce0cabd --- electrum/gui/qml/qelnpaymentdetails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/gui/qml/qelnpaymentdetails.py b/electrum/gui/qml/qelnpaymentdetails.py index 6644ad295..81c391502 100644 --- a/electrum/gui/qml/qelnpaymentdetails.py +++ b/electrum/gui/qml/qelnpaymentdetails.py @@ -87,7 +87,7 @@ class QELnPaymentDetails(QObject): self._logger.error('wallet undefined') return - if self._key not in self._wallet.wallet.lnworker.payments: + if self._key not in self._wallet.wallet.lnworker.payment_info: self._logger.error('payment_hash not found') return From e9a174711be4f0f885e3cf7853dd42b57d7ff770 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 21 Jun 2022 00:01:30 +0200 Subject: [PATCH 177/218] UI on Wallets screen. Add active/not active/current indicator tags. initial wallet delete/change password boilerplate --- electrum/gui/qml/components/Constants.qml | 3 +- electrum/gui/qml/components/Wallets.qml | 41 ++++++++++++++++---- electrum/gui/qml/components/controls/Tag.qml | 29 ++++++++++++++ electrum/gui/qml/components/main.qml | 14 +++++++ electrum/gui/qml/qedaemon.py | 10 ++++- electrum/gui/qml/qewallet.py | 15 ++++--- 6 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 electrum/gui/qml/components/controls/Tag.qml diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index da633b95b..f6eb7293d 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -2,7 +2,8 @@ import QtQuick 2.6 import QtQuick.Controls.Material 2.0 Item { - readonly property int paddingTiny: 4 + readonly property int paddingTiny: 4 //deprecated + readonly property int paddingXXSmall: 4 readonly property int paddingXSmall: 6 readonly property int paddingSmall: 8 readonly property int paddingMedium: 12 diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 9cc941bf3..f38fe29ae 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -35,11 +35,15 @@ Pane { var dialog = app.messageDialog.createObject(rootItem, {'text': qsTr('Really delete this wallet?'), 'yesno': true}) dialog.yesClicked.connect(function() { - Daemon.currentWallet.deleteWallet() + Daemon.delete_wallet(Daemon.currentWallet) }) dialog.open() } + function changePassword() { + // TODO: show set password dialog + } + property QtObject menu: Menu { id: menu MenuItem { @@ -66,7 +70,7 @@ Pane { id: deleteWalletComp MenuItem { icon.color: 'transparent' - enabled: false + enabled: Daemon.currentWallet // != null action: Action { text: qsTr('Delete Wallet'); onTriggered: rootItem.deleteWallet() @@ -231,10 +235,14 @@ Pane { clip: true model: Daemon.availableWallets - delegate: AbstractButton { + delegate: ItemDelegate { width: ListView.view.width height: row.height + onClicked: { + Daemon.load_wallet(model.path) + } + RowLayout { id: row spacing: 10 @@ -247,19 +255,35 @@ Pane { fillMode: Image.PreserveAspectFit Layout.preferredWidth: constants.iconSizeLarge Layout.preferredHeight: constants.iconSizeLarge + Layout.topMargin: constants.paddingSmall + Layout.bottomMargin: constants.paddingSmall } Label { font.pixelSize: constants.fontSizeLarge text: model.name + color: model.active ? Material.foreground : Qt.darker(Material.foreground, 1.20) Layout.fillWidth: true } - Button { - text: 'Open' - onClicked: { - Daemon.load_wallet(model.path) - } + Tag { + visible: Daemon.currentWallet && model.name == Daemon.currentWallet.name + text: qsTr('Current') + border.color: Material.foreground + font.bold: true + labelcolor: Material.foreground + } + Tag { + visible: model.active + text: qsTr('Active') + border.color: 'green' + labelcolor: 'green' + } + Tag { + visible: !model.active + text: qsTr('Not loaded') + border.color: 'grey' + labelcolor: 'grey' } } } @@ -279,6 +303,7 @@ Pane { Connections { target: Daemon function onWalletLoaded() { + Daemon.availableWallets.reload() app.stack.pop() } } diff --git a/electrum/gui/qml/components/controls/Tag.qml b/electrum/gui/qml/components/controls/Tag.qml new file mode 100644 index 000000000..510e059d0 --- /dev/null +++ b/electrum/gui/qml/components/controls/Tag.qml @@ -0,0 +1,29 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +Rectangle { + radius: constants.paddingXSmall + width: layout.width + height: layout.height + color: 'transparent' + border.color: Material.accentColor + + property alias text: label.text + property alias font: label.font + property alias labelcolor: label.color + + RowLayout { + id: layout + + Label { + id: label + Layout.leftMargin: constants.paddingSmall + Layout.rightMargin: constants.paddingSmall + Layout.topMargin: constants.paddingXXSmall + Layout.bottomMargin: constants.paddingXXSmall + font.pixelSize: constants.fontSizeXSmall + } + } +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 775b735a4..606f2aa1d 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -235,4 +235,18 @@ ApplicationWindow dialog.open() } } + + Connections { + target: Daemon + function onAuthRequired() { + var dialog = app.messageDialog.createObject(app, {'text': 'Auth placeholder', 'yesno': true}) + dialog.yesClicked.connect(function() { + Daemon.authProceed() + }) + dialog.noClicked.connect(function() { + Daemon.authCancel() + }) + dialog.open() + } + } } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 8d049b31a..28d602477 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -13,6 +13,7 @@ from electrum.wallet_db import WalletDB from .qewallet import QEWallet from .qewalletdb import QEWalletDB from .qefx import QEFX +from .auth import AuthMixin, auth_protect # wallet list model. supports both wallet basenames (wallet file basenames) # and whole Wallet instances (loaded wallets) @@ -84,7 +85,7 @@ class QEAvailableWalletListModel(QEWalletListModel): wallet = self.daemon.get_wallet(path) self.add_wallet(wallet_path = path, wallet = wallet) -class QEDaemon(QObject): +class QEDaemon(AuthMixin, QObject): def __init__(self, daemon, parent=None): super().__init__(parent) self.daemon = daemon @@ -145,6 +146,13 @@ class QEDaemon(QObject): self._logger.error(str(e)) self.walletOpenError.emit(str(e)) + @pyqtSlot(QEWallet) + @auth_protect + def delete_wallet(self, wallet): + path = wallet.wallet.storage.path + self._logger.debug('Ok to delete wallet with path %s' % path) + # TODO checks, e.g. existing LN channels, unpaid requests, etc + self.daemon.stop_wallet(path) @pyqtProperty('QString') def path(self): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 98467a73b..8333ac501 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -235,7 +235,7 @@ class QEWallet(AuthMixin, QObject): return self._channelModel nameChanged = pyqtSignal() - @pyqtProperty('QString', notify=nameChanged) + @pyqtProperty(str, notify=nameChanged) def name(self): return self.wallet.basename() @@ -252,7 +252,7 @@ class QEWallet(AuthMixin, QObject): def hasSeed(self): return self.wallet.has_seed() - @pyqtProperty('QString', notify=dataChanged) + @pyqtProperty(str, notify=dataChanged) def txinType(self): return self.wallet.get_txin_type(self.wallet.dummy_address()) @@ -272,7 +272,7 @@ class QEWallet(AuthMixin, QObject): def isHardware(self): return self.wallet.storage.is_encrypted_with_hw_device() - @pyqtProperty('QString', notify=dataChanged) + @pyqtProperty(str, notify=dataChanged) def derivationPrefix(self): keystores = self.wallet.get_keystores() if len(keystores) > 1: @@ -288,7 +288,6 @@ class QEWallet(AuthMixin, QObject): @pyqtProperty(QEAmount, notify=balanceChanged) def frozenBalance(self): c, u, x = self.wallet.get_frozen_balance() - self._logger.info('frozen balance: ' + str(c) + ' ' + str(u) + ' ' + str(x) + ' ') self._frozenbalance = QEAmount(amount_sat=c+x) return self._frozenbalance @@ -300,7 +299,6 @@ class QEWallet(AuthMixin, QObject): @pyqtProperty(QEAmount, notify=balanceChanged) def confirmedBalance(self): c, u, x = self.wallet.get_balance() - self._logger.info('balance: ' + str(c) + ' ' + str(u) + ' ' + str(x) + ' ') self._confirmedbalance = QEAmount(amount_sat=c+x) return self._confirmedbalance @@ -484,3 +482,10 @@ class QEWallet(AuthMixin, QObject): @pyqtSlot('QString', result='QVariant') def get_invoice(self, key: str): return self._invoiceModel.get_model_invoice(key) + + @pyqtSlot(str) + @auth_protect + def set_password(self, password): + storage = self.wallet.storage + self._logger.debug('Ok to set password for wallet with path %s' % storage.path) + # TODO From 9243f3b896de82b69a4f6a609eb3d19301648f7e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 21 Jun 2022 14:11:03 +0200 Subject: [PATCH 178/218] implement wallet password change. implement wallet delete (though actual wallet file delete is left out still) --- .../gui/qml/components/WalletMainView.qml | 2 +- electrum/gui/qml/components/Wallets.qml | 22 +++- .../components/controls/PasswordDialog.qml | 115 ++++++++++++++++++ electrum/gui/qml/components/main.qml | 31 +++-- electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qedaemon.py | 5 + electrum/gui/qml/qewallet.py | 46 +++++-- 7 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 electrum/gui/qml/components/controls/PasswordDialog.qml diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 342221e6c..9663f9d69 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -6,7 +6,7 @@ import QtQml 2.6 Item { id: rootItem - property string title: Daemon.currentWallet.name + property string title: Daemon.currentWallet ? Daemon.currentWallet.name : '' property QtObject menu: Menu { id: menu diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index f38fe29ae..ec19f195f 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -41,7 +41,8 @@ Pane { } function changePassword() { - // TODO: show set password dialog + // trigger dialog via wallet (auth then signal) + Daemon.currentWallet.start_change_password() } property QtObject menu: Menu { @@ -58,7 +59,7 @@ Pane { id: changePasswordComp MenuItem { icon.color: 'transparent' - enabled: false + enabled: Daemon.currentWallet // != null action: Action { text: qsTr('Change Password'); onTriggered: rootItem.changePassword() @@ -308,6 +309,23 @@ Pane { } } + Connections { + target: Daemon.currentWallet + function onRequestNewPassword() { // new wallet password + var dialog = app.passwordDialog.createObject(app, + { + 'confirmPassword': true, + 'title': qsTr('Enter new password'), + 'infotext': qsTr('If you forget your password, you\'ll need to\ + restore from seed. Please make sure you have your seed stored safely') + } ) + dialog.accepted.connect(function() { + Daemon.currentWallet.set_password(dialog.password) + }) + dialog.open() + } + } + Component { id: share GenericShareDialog { diff --git a/electrum/gui/qml/components/controls/PasswordDialog.qml b/electrum/gui/qml/components/controls/PasswordDialog.qml new file mode 100644 index 000000000..563f9c9a4 --- /dev/null +++ b/electrum/gui/qml/components/controls/PasswordDialog.qml @@ -0,0 +1,115 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +Dialog { + id: passworddialog + + title: qsTr("Enter Password") + + property bool confirmPassword: false + property string password + property string infotext + + parent: Overlay.overlay + modal: true + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + Overlay.modal: Rectangle { + color: "#aa000000" + } + + header: GridLayout { + columns: 2 + rowSpacing: 0 + + Image { + source: "../../../icons/lock.png" + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + Layout.leftMargin: constants.paddingMedium + Layout.topMargin: constants.paddingMedium + Layout.bottomMargin: constants.paddingMedium + } + + Label { + text: title + elide: Label.ElideRight + Layout.fillWidth: true + topPadding: constants.paddingXLarge + bottomPadding: constants.paddingXLarge + font.bold: true + font.pixelSize: constants.fontSizeMedium + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.leftMargin: constants.paddingXXSmall + Layout.rightMargin: constants.paddingXXSmall + height: 1 + color: Qt.rgba(0,0,0,0.5) + } + } + + ColumnLayout { + width: parent.width + + InfoTextArea { + visible: infotext + text: infotext + Layout.preferredWidth: password_layout.width + } + + GridLayout { + id: password_layout + columns: 2 + Layout.fillWidth: true + Layout.margins: constants.paddingXXLarge + + Label { + text: qsTr('Password') + } + + TextField { + id: pw_1 + echoMode: TextInput.Password + } + + Label { + text: qsTr('Password (again)') + visible: confirmPassword + } + + TextField { + id: pw_2 + echoMode: TextInput.Password + visible: confirmPassword + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingXXLarge + + Button { + text: qsTr("Ok") + enabled: confirmPassword ? pw_1.text == pw_2.text : true + onClicked: { + password = pw_1.text + passworddialog.accept() + } + } + Button { + text: qsTr("Cancel") + onClicked: { + passworddialog.reject() + } + } + } + } + +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 606f2aa1d..9320857d4 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -171,6 +171,14 @@ ApplicationWindow } } + property alias passwordDialog: _passwordDialog + Component { + id: _passwordDialog + PasswordDialog { + onClosed: destroy() + } + } + NotificationPopup { id: notificationPopup } @@ -225,14 +233,23 @@ ApplicationWindow Connections { target: Daemon.currentWallet function onAuthRequired() { - var dialog = app.messageDialog.createObject(app, {'text': 'Auth placeholder', 'yesno': true}) - dialog.yesClicked.connect(function() { + if (Daemon.currentWallet.verify_password('')) { + // wallet has no password Daemon.currentWallet.authProceed() - }) - dialog.noClicked.connect(function() { - Daemon.currentWallet.authCancel() - }) - dialog.open() + } else { + var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) + dialog.accepted.connect(function() { + if (Daemon.currentWallet.verify_password(dialog.password)) { + Daemon.currentWallet.authProceed() + } else { + Daemon.currentWallet.authCancel() + } + }) + dialog.rejected.connect(function() { + Daemon.currentWallet.authCancel() + }) + dialog.open() + } } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 6858982d5..75893e9cb 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -52,6 +52,8 @@ class QEAppController(QObject): def on_wallet_loaded(self): qewallet = self._qedaemon.currentWallet + if not qewallet: + return # attach to the wallet user notification events # connect only once try: diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 28d602477..c531a5df4 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -152,7 +152,12 @@ class QEDaemon(AuthMixin, QObject): path = wallet.wallet.storage.path self._logger.debug('Ok to delete wallet with path %s' % path) # TODO checks, e.g. existing LN channels, unpaid requests, etc + self._logger.debug('Not deleting yet, just unloading for now') + # TODO actually delete + # TODO walletLoaded signal is confusing self.daemon.stop_wallet(path) + self._current_wallet = None + self.walletLoaded.emit() @pyqtProperty('QString') def path(self): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 8333ac501..b72dd2012 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -7,9 +7,10 @@ import threading from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer from electrum.i18n import _ -from electrum.util import register_callback, Satoshis, format_time, parse_max_spend +from electrum.util import register_callback, Satoshis, format_time, parse_max_spend, InvalidPassword from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet +from electrum.storage import StorageEncryptionVersion from electrum import bitcoin from electrum.transaction import PartialTxOutput from electrum.invoices import (Invoice, InvoiceError, @@ -331,7 +332,7 @@ class QEWallet(AuthMixin, QObject): self.wallet.init_lightning(password=None) # TODO pass password if needed self.isLightningChanged.emit() - @pyqtSlot('QString', int, int, bool) + @pyqtSlot(str, int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): self._logger.info('send_onchain: %s %d' % (address,amount)) coins = self.wallet.get_spendable_coins(None) @@ -437,9 +438,9 @@ class QEWallet(AuthMixin, QObject): return req_key, addr - @pyqtSlot(QEAmount, 'QString', int) - @pyqtSlot(QEAmount, 'QString', int, bool) - @pyqtSlot(QEAmount, 'QString', int, bool, bool) + @pyqtSlot(QEAmount, str, int) + @pyqtSlot(QEAmount, str, int, bool) + @pyqtSlot(QEAmount, str, int, bool, bool) def create_request(self, amount: QEAmount, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False): try: if is_lightning: @@ -463,29 +464,52 @@ class QEWallet(AuthMixin, QObject): self._requestModel.add_invoice(self.wallet.get_request(key)) self.requestCreateSuccess.emit() - @pyqtSlot('QString') + @pyqtSlot(str) def delete_request(self, key: str): self._logger.debug('delete req %s' % key) self.wallet.delete_request(key) self._requestModel.delete_invoice(key) - @pyqtSlot('QString', result='QVariant') + @pyqtSlot(str, result='QVariant') def get_request(self, key: str): return self._requestModel.get_model_invoice(key) - @pyqtSlot('QString') + @pyqtSlot(str) def delete_invoice(self, key: str): self._logger.debug('delete inv %s' % key) self.wallet.delete_invoice(key) self._invoiceModel.delete_invoice(key) - @pyqtSlot('QString', result='QVariant') + @pyqtSlot(str, result='QVariant') def get_invoice(self, key: str): return self._invoiceModel.get_model_invoice(key) - @pyqtSlot(str) + @pyqtSlot(str, result=bool) + def verify_password(self, password): + try: + self.wallet.storage.check_password(password) + return True + except InvalidPassword as e: + return False + + requestNewPassword = pyqtSignal() + @pyqtSlot() @auth_protect + def start_change_password(self): + self.requestNewPassword.emit() + + @pyqtSlot(str) def set_password(self, password): storage = self.wallet.storage + + # HW wallet not supported yet + if storage.is_encrypted_with_hw_device(): + return + self._logger.debug('Ok to set password for wallet with path %s' % storage.path) - # TODO + if password: + enc_version = StorageEncryptionVersion.USER_PASSWORD + else: + enc_version = StorageEncryptionVersion.PLAINTEXT + storage.set_password(password, enc_version=enc_version) + self.wallet.save_db() From 329bbaff3df24c2aa0d99bf47fd18a57dcffe777 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 21 Jun 2022 14:11:56 +0200 Subject: [PATCH 179/218] tabbar minor --- electrum/gui/qml/components/WalletMainView.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 9663f9d69..fba989b83 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -136,6 +136,7 @@ Item { TabBar { id: tabbar + position: TabBar.Footer Layout.fillWidth: true currentIndex: swipeview.currentIndex TabButton { From 04ce548e42176c2bb6262dfacf97e76de236bf88 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 22 Jun 2022 11:36:25 +0200 Subject: [PATCH 180/218] initial lightning channel details, action menu --- .../gui/qml/components/ChannelDetails.qml | 248 ++++++++++++++++++ electrum/gui/qml/components/Channels.qml | 4 +- electrum/gui/qml/qeapp.py | 2 + electrum/gui/qml/qechanneldetails.py | 132 ++++++++++ electrum/gui/qml/qechannellistmodel.py | 6 +- electrum/gui/qml/qeinvoice.py | 3 +- 6 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 electrum/gui/qml/components/ChannelDetails.qml create mode 100644 electrum/gui/qml/qechanneldetails.py diff --git a/electrum/gui/qml/components/ChannelDetails.qml b/electrum/gui/qml/components/ChannelDetails.qml new file mode 100644 index 000000000..265cae031 --- /dev/null +++ b/electrum/gui/qml/components/ChannelDetails.qml @@ -0,0 +1,248 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string channelid + + property string title: qsTr("Channel details") + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Backup'); + enabled: false + onTriggered: {} + //icon.source: '../../icons/wallet.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Close channel'); + enabled: false + onTriggered: {} + //icon.source: '../../icons/wallet.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Force-close'); + enabled: false + onTriggered: {} + //icon.source: '../../icons/wallet.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: channeldetails.frozenForSending ? qsTr('Unfreeze (for sending)') : qsTr('Freeze (for sending)') + onTriggered: channeldetails.freezeForSending() + //icon.source: '../../icons/wallet.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: channeldetails.frozenForReceiving ? qsTr('Unfreeze (for receiving)') : qsTr('Freeze (for receiving)') + onTriggered: channeldetails.freezeForReceiving() + //icon.source: '../../icons/wallet.png' + } + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Channel name') + color: Material.accentColor + } + + Label { + text: channeldetails.name + } + + Label { + text: qsTr('Short channel ID') + color: Material.accentColor + } + + Label { + text: channeldetails.short_cid + } + + Label { + text: qsTr('State') + color: Material.accentColor + } + + Label { + text: channeldetails.state + } + + Label { + text: qsTr('Initiator') + color: Material.accentColor + } + + Label { + text: channeldetails.initiator + } + + Label { + text: qsTr('Capacity') + color: Material.accentColor + } + + RowLayout { + Label { + font.family: FixedFont + text: Config.formatSats(channeldetails.capacity) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(channeldetails.capacity) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + + Label { + text: qsTr('Can send') + color: Material.accentColor + } + + RowLayout { + visible: !channeldetails.frozenForSending && channeldetails.isOpen + Label { + font.family: FixedFont + text: Config.formatSats(channeldetails.canSend) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(channeldetails.canSend) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + Label { + visible: channeldetails.frozenForSending && channeldetails.isOpen + text: qsTr('n/a (frozen)') + } + Label { + visible: !channeldetails.isOpen + text: qsTr('n/a (channel not open)') + } + + Label { + text: qsTr('Can Receive') + color: Material.accentColor + } + + RowLayout { + visible: !channeldetails.frozenForReceiving && channeldetails.isOpen + Label { + font.family: FixedFont + text: Config.formatSats(channeldetails.canReceive) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(channeldetails.canReceive) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + Label { + visible: channeldetails.frozenForReceiving && channeldetails.isOpen + text: qsTr('n/a (frozen)') + } + Label { + visible: !channeldetails.isOpen + text: qsTr('n/a (channel not open)') + } + + Label { + text: qsTr('Channel type') + color: Material.accentColor + } + + Label { + text: channeldetails.channelType + } + + Label { + text: qsTr('Remote node ID') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: channeldetails.pubkey + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Channel node ID'), 'text': channeldetails.pubkey }) + dialog.open() + } + } + } + } + + } + } + + ChannelDetails { + id: channeldetails + wallet: Daemon.currentWallet + channelid: root.channelid + } + + Component { + id: share + GenericShareDialog {} + } +} diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index 5462edc7f..a401e0195 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -108,7 +108,9 @@ Pane { model: Daemon.currentWallet.channelModel delegate: ChannelDelegate { - //highlighted: ListView.isCurrentItem + onClicked: { + app.stack.push(Qt.resolvedUrl('ChannelDetails.qml'), { 'channelid': model.cid }) + } } ScrollIndicator.vertical: ScrollIndicator { } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 75893e9cb..a118b705b 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -25,6 +25,7 @@ from .qeaddressdetails import QEAddressDetails from .qetxdetails import QETxDetails from .qechannelopener import QEChannelOpener from .qelnpaymentdetails import QELnPaymentDetails +from .qechanneldetails import QEChannelDetails notification = None @@ -149,6 +150,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails') + qmlRegisterType(QEChannelDetails, 'org.electrum', 1, 0, 'ChannelDetails') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') diff --git a/electrum/gui/qml/qechanneldetails.py b/electrum/gui/qml/qechanneldetails.py new file mode 100644 index 000000000..6a21a0e46 --- /dev/null +++ b/electrum/gui/qml/qechanneldetails.py @@ -0,0 +1,132 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS + +from electrum.logging import get_logger +from electrum.util import register_callback, unregister_callback +from electrum.lnutil import LOCAL, REMOTE + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QEChannelDetails(QObject): + + _logger = get_logger(__name__) + _wallet = None + _channelid = None + _channel = None + + channelChanged = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + register_callback(self.on_network, ['channel']) # TODO unregister too + + def on_network(self, event, *args): + if event == 'channel': + wallet, channel = args + if wallet == self._wallet.wallet and self._channelid == channel.channel_id.hex(): + self.channelChanged.emit() + + 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.walletChanged.emit() + + channelidChanged = pyqtSignal() + @pyqtProperty(str, notify=channelidChanged) + def channelid(self): + return self._channelid + + @channelid.setter + def channelid(self, channelid: str): + if self._channelid != channelid: + self._channelid = channelid + if channelid: + self.load() + self.channelidChanged.emit() + + def load(self): + lnchannels = self._wallet.wallet.lnworker.channels + for channel in lnchannels.values(): + self._logger.debug('%s == %s ?' % (self._channelid, channel.channel_id)) + if self._channelid == channel.channel_id.hex(): + self._channel = channel + self.channelChanged.emit() + + @pyqtProperty(str, notify=channelChanged) + def name(self): + if not self._channel: + return + return self._wallet.wallet.lnworker.get_node_alias(self._channel.node_id) or self._channel.node_id.hex() + + @pyqtProperty(str, notify=channelChanged) + def pubkey(self): + return self._channel.node_id.hex() #if self._channel else '' + + @pyqtProperty(str, notify=channelChanged) + def short_cid(self): + return self._channel.short_id_for_GUI() + + @pyqtProperty(str, notify=channelChanged) + def state(self): + return self._channel.get_state_for_GUI() + + @pyqtProperty(str, notify=channelChanged) + def initiator(self): + return 'Local' if self._channel.constraints.is_initiator else 'Remote' + + @pyqtProperty(QEAmount, notify=channelChanged) + def capacity(self): + self._capacity = QEAmount(amount_sat=self._channel.get_capacity()) + return self._capacity + + @pyqtProperty(QEAmount, notify=channelChanged) + def canSend(self): + self._can_send = QEAmount(amount_sat=self._channel.available_to_spend(LOCAL)/1000) + return self._can_send + + @pyqtProperty(QEAmount, notify=channelChanged) + def canReceive(self): + self._can_receive = QEAmount(amount_sat=self._channel.available_to_spend(REMOTE)/1000) + return self._can_receive + + @pyqtProperty(bool, notify=channelChanged) + def frozenForSending(self): + return self._channel.is_frozen_for_sending() + + @pyqtProperty(bool, notify=channelChanged) + def frozenForReceiving(self): + return self._channel.is_frozen_for_receiving() + + @pyqtProperty(str, notify=channelChanged) + def channelType(self): + return self._channel.storage['channel_type'].name_minimal + + @pyqtProperty(bool, notify=channelChanged) + def isOpen(self): + return self._channel.is_open() + + @pyqtSlot() + def freezeForSending(self): + lnworker = self._channel.lnworker + if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id): + #self.is_frozen_for_sending = not self.is_frozen_for_sending + self._channel.set_frozen_for_sending(not self.frozenForSending) + self.channelChanged.emit() + else: + self._logger.debug('TODO: messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP') + + @pyqtSlot() + def freezeForReceiving(self): + lnworker = self._channel.lnworker + if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id): + #self.is_frozen_for_sending = not self.is_frozen_for_sending + self._channel.set_frozen_for_receiving(not self.frozenForReceiving) + self.channelChanged.emit() + else: + self._logger.debug('TODO: messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP') diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 77a05cc2b..296b885ee 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -14,7 +14,7 @@ class QEChannelListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('cid','state','initiator','capacity','can_send','can_receive', - 'l_csv_delat','r_csv_delay','send_frozen','receive_frozen', + 'l_csv_delay','r_csv_delay','send_frozen','receive_frozen', 'type','node_id','node_alias','short_cid','funding_tx') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) @@ -81,7 +81,7 @@ class QEChannelListModel(QAbstractListModel): def channel_to_model(self, lnc): lnworker = self.wallet.lnworker item = {} - item['channel_id'] = lnc.channel_id + item['cid'] = lnc.channel_id.hex() item['node_alias'] = lnworker.get_node_alias(lnc.node_id) or lnc.node_id.hex() item['short_cid'] = lnc.short_id_for_GUI() item['state'] = lnc.get_state_for_GUI() @@ -114,7 +114,7 @@ class QEChannelListModel(QAbstractListModel): def on_channel_updated(self, channel): i = 0 for c in self.channels: - if c['channel_id'] == channel.channel_id: + if c['cid'] == channel.channel_id: self.do_update(i,channel) break i = i + 1 diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 9dbd3daa5..e74264d55 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -57,9 +57,8 @@ class QEInvoice(QObject): invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) - def __init__(self, config, parent=None): + def __init__(self, parent=None): super().__init__(parent) - self.config = config self.clear() @pyqtProperty(int, notify=invoiceChanged) From 12d726efc23edb9d807e678c22c2821371626c6c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 27 Jun 2022 15:08:52 +0200 Subject: [PATCH 181/218] split user entered fields object from invoice uri parsing object --- .../gui/qml/components/ConfirmTxDialog.qml | 9 +- electrum/gui/qml/components/Send.qml | 58 +++++-- electrum/gui/qml/qeapp.py | 4 +- electrum/gui/qml/qeinvoice.py | 153 ++++++++++++++++-- 4 files changed, 189 insertions(+), 35 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 3e2ef3161..2123e2749 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -17,6 +17,9 @@ Dialog { property alias amountLabelText: amountLabel.text property alias sendButtonText: sendButton.text + signal txcancelled + signal txaccepted + title: qsTr('Confirm Transaction') // copy these to finalizer @@ -206,7 +209,10 @@ Dialog { Button { text: qsTr('Cancel') - onClicked: dialog.close() + onClicked: { + txcancelled() + dialog.close() + } } Button { @@ -214,6 +220,7 @@ Dialog { text: qsTr('Pay') enabled: finalizer.valid onClicked: { + txaccepted() finalizer.send_onchain() dialog.close() } diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 2a32c7325..df710c0e9 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -15,6 +15,7 @@ Pane { recipient.text = '' amount.text = '' message.text = '' + is_max.checked = false } GridLayout { @@ -44,8 +45,9 @@ Pane { wrapMode: Text.Wrap placeholderText: qsTr('Paste address or invoice') onTextChanged: { - if (activeFocus) - invoice.recipient = text + //if (activeFocus) + //userEnteredPayment.recipient = text + userEnteredPayment.recipient = recipient.text } } @@ -79,7 +81,7 @@ Pane { fiatfield: amountFiat Layout.preferredWidth: parent.width /3 onTextChanged: { - invoice.create_invoice(recipient.text, is_max.checked ? MAX : Config.unitsToSats(amount.text), message.text) + userEnteredPayment.amount = is_max.checked ? MAX : Config.unitsToSats(amount.text) } } @@ -94,7 +96,7 @@ Pane { id: is_max text: qsTr('Max') onCheckedChanged: { - invoice.create_invoice(recipient.text, is_max.checked ? MAX : Config.unitsToSats(amount.text), message.text) + userEnteredPayment.amount = is_max.checked ? MAX : Config.unitsToSats(amount.text) } } } @@ -125,7 +127,7 @@ Pane { Layout.columnSpan: 2 Layout.fillWidth: true onTextChanged: { - invoice.create_invoice(recipient.text, is_max.checked ? MAX : Config.unitsToSats(amount.text), message.text) + userEnteredPayment.message = message.text } } @@ -136,29 +138,30 @@ Pane { Button { text: qsTr('Save') - enabled: invoice.canSave + enabled: userEnteredPayment.canSave icon.source: '../../icons/save.png' onClicked: { - invoice.save_invoice() - invoice.clear() + userEnteredPayment.save_invoice() + userEnteredPayment.clear() rootItem.clear() } } Button { text: qsTr('Pay now') - enabled: invoice.canPay + enabled: userEnteredPayment.canPay icon.source: '../../icons/confirmed.png' onClicked: { - invoice.save_invoice() var dialog = confirmPaymentDialog.createObject(app, { 'address': recipient.text, - 'satoshis': Config.unitsToSats(amount.text), + 'satoshis': is_max.checked ? MAX : Config.unitsToSats(amount.text), 'message': message.text }) + dialog.txaccepted.connect(function() { + userEnteredPayment.clear() + rootItem.clear() + }) dialog.open() - invoice.clear() - rootItem.clear() } } @@ -293,6 +296,26 @@ Pane { FocusScope { id: parkFocus } } + + UserEnteredPayment { + id: userEnteredPayment + wallet: Daemon.currentWallet + + //onValidationError: { + //if (recipient.activeFocus) { + //// no popups when editing + //return + //} + //var dialog = app.messageDialog.createObject(app, {'text': message }) + //dialog.open() +//// rootItem.clear() + //} + + onInvoiceSaved: { + Daemon.currentWallet.invoiceModel.init_model() + } + } + Invoice { id: invoice wallet: Daemon.currentWallet @@ -314,11 +337,12 @@ Pane { } } onValidationSuccess: { - // address only -> fill form fields + // address only -> fill form fields and clear this instance // else -> show invoice confirmation dialog - if (invoiceType == Invoice.OnchainOnlyAddress) + if (invoiceType == Invoice.OnchainOnlyAddress) { recipient.text = invoice.recipient - else { + invoice.clear() + } else { var dialog = invoiceDialog.createObject(rootItem, {'invoice': invoice}) dialog.open() } @@ -326,8 +350,8 @@ Pane { onInvoiceCreateError: console.log(code + ' ' + message) onInvoiceSaved: { - console.log('invoice got saved') Daemon.currentWallet.invoiceModel.init_model() } } + } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index a118b705b..8c9e1d12d 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -19,7 +19,7 @@ from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin from .qefx import QEFX from .qetxfinalizer import QETxFinalizer -from .qeinvoice import QEInvoice +from .qeinvoice import QEInvoice, QEUserEnteredPayment from .qetypes import QEAmount from .qeaddressdetails import QEAddressDetails from .qetxdetails import QETxDetails @@ -146,6 +146,8 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') + qmlRegisterType(QEUserEnteredPayment, 'org.electrum', 1, 0, 'UserEnteredPayment') + qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index e74264d55..2a757a121 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -305,33 +305,154 @@ class QEInvoice(QObject): self._wallet.wallet.save_invoice(self._effectiveInvoice) self.invoiceSaved.emit() - @pyqtSlot(str, QEAmount, str) - def create_invoice(self, address: str, amount: QEAmount, message: str): - # create invoice from user entered fields - # (any other type of invoice is created from parsing recipient) - self._logger.debug('creating invoice to %s, amount=%s, message=%s' % (address, repr(amount), message)) - self.clear() +class QEUserEnteredPayment(QObject): + _logger = get_logger(__name__) + _wallet = None + _recipient = None + _message = None + _amount = QEAmount() + _key = None + _canSave = False + _canPay = False + + validationError = pyqtSignal([str,str], arguments=['code','message']) + invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) + invoiceSaved = pyqtSignal() + + 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.walletChanged.emit() + + recipientChanged = pyqtSignal() + @pyqtProperty(str, notify=recipientChanged) + def recipient(self): + return self._recipient + + @recipient.setter + def recipient(self, recipient: str): + if self._recipient != recipient: + self._recipient = recipient + self.validate() + self.recipientChanged.emit() + + messageChanged = pyqtSignal() + @pyqtProperty(str, notify=messageChanged) + def message(self): + return self._message + + @message.setter + def message(self, message): + if self._message != message: + self._message = message + self.messageChanged.emit() + + amountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=amountChanged) + def amount(self): + return self._amount + + @amount.setter + def amount(self, amount): + if self._amount != amount: + self._amount = amount + self.validate() + self.amountChanged.emit() + + canSaveChanged = pyqtSignal() + @pyqtProperty(bool, notify=canSaveChanged) + def canSave(self): + return self._canSave + + @canSave.setter + def canSave(self, canSave): + if self._canSave != canSave: + self._canSave = canSave + self.canSaveChanged.emit() + + canPayChanged = pyqtSignal() + @pyqtProperty(bool, notify=canPayChanged) + def canPay(self): + return self._canPay + + @canPay.setter + def canPay(self, canPay): + if self._canPay != canPay: + self._canPay = canPay + self.canPayChanged.emit() - if not address: - self.invoiceCreateError.emit('fatal', _('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) + keyChanged = pyqtSignal() + @pyqtProperty(bool, notify=keyChanged) + def key(self): + return self._key + + @key.setter + def key(self, key): + if self._key != key: + self._key = key + self.keyChanged.emit() + + def validate(self): + self.canPay = False + self.canSave = False + self._logger.debug('validate') + + if not self._recipient: + self.validationError.emit('recipient', _('Recipient not specified.')) return - if not bitcoin.is_address(address): - self.invoiceCreateError.emit('fatal', _('Invalid Bitcoin address')) + if not bitcoin.is_address(self._recipient): + self.validationError.emit('recipient', _('Invalid Bitcoin address')) return - if amount.isEmpty: - self.invoiceCreateError.emit('fatal', _('Invalid amount')) + if self._amount.isEmpty: + self.validationError.emit('amount', _('Invalid amount')) return - inv_amt = '!' if amount.isMax else (amount.satsInt * 1000) # FIXME msat precision from UI? + if self._amount.isMax: + self.canPay = True + else: + self.canSave = True + if self.get_max_spendable() >= self._amount.satsInt: + self.canPay = True + + def get_max_spendable(self): + c, u, x = self._wallet.wallet.get_balance() + #TODO determine real max + return c + + @pyqtSlot() + def save_invoice(self): + assert self.canSave + assert not self._amount.isMax + + self._logger.debug('saving invoice to %s, amount=%s, message=%s' % (self._recipient, repr(self._amount), self._message)) + + inv_amt = self._amount.satsInt try: - outputs = [PartialTxOutput.from_address_and_value(address, inv_amt)] - invoice = self._wallet.wallet.create_invoice(outputs=outputs, message=message, pr=None, URI=None) + outputs = [PartialTxOutput.from_address_and_value(self._recipient, inv_amt)] + self._logger.debug(repr(outputs)) + invoice = self._wallet.wallet.create_invoice(outputs=outputs, message=self._message, pr=None, URI=None) except InvoiceError as e: self.invoiceCreateError.emit('fatal', _('Error creating payment') + ':\n' + str(e)) return - self.set_effective_invoice(invoice) + self.key = self._wallet.wallet.get_key_for_outgoing_invoice(invoice) + self._wallet.wallet.save_invoice(invoice) + self.invoiceSaved.emit() + + @pyqtSlot() + def clear(self): + self._recipient = None + self._amount = QEAmount() + self._message = None + self.canSave = False + self.canPay = False From 4c9f713f9a0041b7b6300f7512af611f48f95ed8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 27 Jun 2022 23:00:58 +0200 Subject: [PATCH 182/218] further separate invoice objects --- electrum/gui/qml/components/InvoiceDialog.qml | 4 +- electrum/gui/qml/components/Send.qml | 2 +- electrum/gui/qml/qeapp.py | 3 +- electrum/gui/qml/qeinvoice.py | 194 ++++++++---------- 4 files changed, 92 insertions(+), 111 deletions(-) diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index bd097f69c..394ffcd42 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -163,9 +163,9 @@ Dialog { Button { text: qsTr('Pay now') icon.source: '../../icons/confirmed.png' - enabled: invoice.invoiceType != Invoice.Invalid // TODO && has funds + enabled: invoice.invoiceType != Invoice.Invalid && invoice.canPay onClicked: { - if (invoice_key == '') + if (invoice_key == '') // save invoice if not retrieved from key invoice.save_invoice() dialog.close() if (invoice.invoiceType == Invoice.OnchainInvoice) { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index df710c0e9..5dcfde0d8 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -316,7 +316,7 @@ Pane { } } - Invoice { + InvoiceParser { id: invoice wallet: Daemon.currentWallet onValidationError: { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 8c9e1d12d..eba9be641 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -19,7 +19,7 @@ from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin from .qefx import QEFX from .qetxfinalizer import QETxFinalizer -from .qeinvoice import QEInvoice, QEUserEnteredPayment +from .qeinvoice import QEInvoice, QEInvoiceParser, QEUserEnteredPayment from .qetypes import QEAmount from .qeaddressdetails import QEAddressDetails from .qetxdetails import QETxDetails @@ -146,6 +146,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') + qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser') qmlRegisterType(QEUserEnteredPayment, 'org.electrum', 1, 0, 'UserEnteredPayment') qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 2a757a121..ef21ad254 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -17,9 +17,6 @@ from .qewallet import QEWallet from .qetypes import QEAmount class QEInvoice(QObject): - - _logger = get_logger(__name__) - class Type: Invalid = -1 OnchainOnlyAddress = 0 @@ -40,13 +37,74 @@ class QEInvoice(QObject): Q_ENUMS(Type) Q_ENUMS(Status) + _logger = get_logger(__name__) + _wallet = None - _invoiceType = Type.Invalid - _recipient = '' - _effectiveInvoice = None _canSave = False _canPay = False - _key = '' + _key = None + + def __init__(self, parent=None): + super().__init__(parent) + + 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.walletChanged.emit() + + canSaveChanged = pyqtSignal() + @pyqtProperty(bool, notify=canSaveChanged) + def canSave(self): + return self._canSave + + @canSave.setter + def canSave(self, canSave): + if self._canSave != canSave: + self._canSave = canSave + self.canSaveChanged.emit() + + canPayChanged = pyqtSignal() + @pyqtProperty(bool, notify=canPayChanged) + def canPay(self): + return self._canPay + + @canPay.setter + def canPay(self, canPay): + if self._canPay != canPay: + self._canPay = canPay + self.canPayChanged.emit() + + keyChanged = pyqtSignal() + @pyqtProperty(str, notify=keyChanged) + def key(self): + return self._key + + @key.setter + def key(self, key): + if self._key != key: + self._key = key + self.keyChanged.emit() + + def get_max_spendable_onchain(self): + c, u, x = self._wallet.wallet.get_balance() + #TODO determine real max + return c + + +class QEInvoiceParser(QEInvoice): + + _logger = get_logger(__name__) + + _invoiceType = QEInvoice.Type.Invalid + _recipient = '' + _effectiveInvoice = None + _amount = QEAmount() invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal() @@ -66,20 +124,9 @@ class QEInvoice(QObject): return self._invoiceType # not a qt setter, don't let outside set state - def setInvoiceType(self, invoiceType: Type): + def setInvoiceType(self, invoiceType: QEInvoice.Type): self._invoiceType = invoiceType - 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.walletChanged.emit() - recipientChanged = pyqtSignal() @pyqtProperty(str, notify=recipientChanged) def recipient(self): @@ -128,49 +175,18 @@ class QEInvoice(QObject): status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) return self._effectiveInvoice.get_status_str(status) - keyChanged = pyqtSignal() - @pyqtProperty(str, notify=keyChanged) - def key(self): - return self._key - - @key.setter - def key(self, key): - if self._key != key: - self._key = key - self.keyChanged.emit() - # single address only, TODO: n outputs @pyqtProperty(str, notify=invoiceChanged) def address(self): return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' - @pyqtProperty(bool, notify=invoiceChanged) - def canSave(self): - return self._canSave - - @canSave.setter - def canSave(self, _canSave): - if self._canSave != _canSave: - self._canSave = _canSave - self.invoiceChanged.emit() - - @pyqtProperty(bool, notify=invoiceChanged) - def canPay(self): - return self._canPay - - @canPay.setter - def canPay(self, _canPay): - if self._canPay != _canPay: - self._canPay = _canPay - self.invoiceChanged.emit() - @pyqtSlot() def clear(self): self.recipient = '' self.setInvoiceType(QEInvoice.Type.Invalid) self._bip21 = None - self._canSave = False - self._canPay = False + self.canSave = False + self.canPay = False self.invoiceChanged.emit() # don't parse the recipient string, but init qeinvoice from an invoice key @@ -185,16 +201,27 @@ class QEInvoice(QObject): def set_effective_invoice(self, invoice: Invoice): self._effectiveInvoice = invoice + if invoice.is_lightning(): self.setInvoiceType(QEInvoice.Type.LightningInvoice) else: self.setInvoiceType(QEInvoice.Type.OnchainInvoice) - # TODO check if exists? + self.canSave = True - self.canPay = True # TODO + + if self.invoiceType == QEInvoice.Type.LightningInvoice: + if self.get_max_spendable_lightning() >= self.amount.satsInt: + self.canPay = True + elif self.invoiceType == QEInvoice.Type.OnchainInvoice: + if self.get_max_spendable_onchain() >= self.amount.satsInt: + self.canPay = True + self.invoiceChanged.emit() self.statusChanged.emit() + def get_max_spendable_lightning(self): + return self._wallet.wallet.lnworker.num_sats_can_send() + def setValidAddressOnly(self): self._logger.debug('setValidAddressOnly') self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) @@ -306,30 +333,20 @@ class QEInvoice(QObject): self.invoiceSaved.emit() -class QEUserEnteredPayment(QObject): +class QEUserEnteredPayment(QEInvoice): _logger = get_logger(__name__) - _wallet = None + _recipient = None _message = None _amount = QEAmount() - _key = None - _canSave = False - _canPay = False validationError = pyqtSignal([str,str], arguments=['code','message']) invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) invoiceSaved = pyqtSignal() - 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.walletChanged.emit() + def __init__(self, parent=None): + super().__init__(parent) + self.clear() recipientChanged = pyqtSignal() @pyqtProperty(str, notify=recipientChanged) @@ -366,38 +383,6 @@ class QEUserEnteredPayment(QObject): self.validate() self.amountChanged.emit() - canSaveChanged = pyqtSignal() - @pyqtProperty(bool, notify=canSaveChanged) - def canSave(self): - return self._canSave - - @canSave.setter - def canSave(self, canSave): - if self._canSave != canSave: - self._canSave = canSave - self.canSaveChanged.emit() - - canPayChanged = pyqtSignal() - @pyqtProperty(bool, notify=canPayChanged) - def canPay(self): - return self._canPay - - @canPay.setter - def canPay(self, canPay): - if self._canPay != canPay: - self._canPay = canPay - self.canPayChanged.emit() - - keyChanged = pyqtSignal() - @pyqtProperty(bool, notify=keyChanged) - def key(self): - return self._key - - @key.setter - def key(self, key): - if self._key != key: - self._key = key - self.keyChanged.emit() def validate(self): self.canPay = False @@ -420,14 +405,9 @@ class QEUserEnteredPayment(QObject): self.canPay = True else: self.canSave = True - if self.get_max_spendable() >= self._amount.satsInt: + if self.get_max_spendable_onchain() >= self._amount.satsInt: self.canPay = True - def get_max_spendable(self): - c, u, x = self._wallet.wallet.get_balance() - #TODO determine real max - return c - @pyqtSlot() def save_invoice(self): assert self.canSave From 2c40a976b9b91f16a8af5c1145b00845c03873a1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 27 Jun 2022 23:01:45 +0200 Subject: [PATCH 183/218] copy & share lightning requests --- electrum/gui/qml/components/RequestDialog.qml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index 6a4b79e7f..d7dc00138 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -107,7 +107,11 @@ Dialog { icon.color: 'transparent' text: 'Copy' onClicked: { - AppController.textToClipboard(_bip21uri) + if (modelItem.is_lightning) + AppController.textToClipboard(modelItem.lightning_invoice) + else + AppController.textToClipboard(_bip21uri) + } } Button { @@ -115,7 +119,10 @@ Dialog { text: 'Share' onClicked: { enabled = false - AppController.doShare(_bip21uri, qsTr('Payment Request')) + if (modelItem.is_lightning) + AppController.doShare(modelItem.lightning_invoice, qsTr('Payment Request')) + else + AppController.doShare(_bip21uri, qsTr('Payment Request')) enabled = true } } From f0d00dca3716d9f13a87f48622af14de70426c73 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 28 Jun 2022 11:47:23 +0200 Subject: [PATCH 184/218] unregister callback when object gets destroyed. turns out we need to use a lambda to have the signal processed, registering the member function somehow never triggers the 'destroyed' signal --- electrum/gui/qml/qechanneldetails.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qechanneldetails.py b/electrum/gui/qml/qechanneldetails.py index 6a21a0e46..96ff41b21 100644 --- a/electrum/gui/qml/qechanneldetails.py +++ b/electrum/gui/qml/qechanneldetails.py @@ -18,7 +18,8 @@ class QEChannelDetails(QObject): def __init__(self, parent=None): super().__init__(parent) - register_callback(self.on_network, ['channel']) # TODO unregister too + register_callback(self.on_network, ['channel']) + self.destroyed.connect(lambda: self.on_destroy()) def on_network(self, event, *args): if event == 'channel': @@ -26,6 +27,9 @@ class QEChannelDetails(QObject): if wallet == self._wallet.wallet and self._channelid == channel.channel_id.hex(): self.channelChanged.emit() + def on_destroy(self): + unregister_callback(self.on_network) + walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) def wallet(self): From c79651f9812065b7bdd2bcccb02bd8f56f664924 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 28 Jun 2022 11:59:24 +0200 Subject: [PATCH 185/218] also unregister callbacks from qewallet and qechannellistmodel on destroy --- electrum/gui/qml/qechannellistmodel.py | 5 ++++- electrum/gui/qml/qewallet.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 296b885ee..50fd55dd2 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -4,7 +4,7 @@ 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, register_callback +from electrum.util import Satoshis, register_callback, unregister_callback from electrum.lnutil import LOCAL, REMOTE from .qetypes import QEAmount @@ -36,6 +36,7 @@ class QEChannelListModel(QAbstractListModel): # methods of this class only, and specifically not be # partials, lambdas or methods of subobjects. Hence... register_callback(self.on_network, interests) + self.destroyed.connect(lambda: self.on_destroy()) def on_network(self, event, *args): if event == 'channel': @@ -56,6 +57,8 @@ class QEChannelListModel(QAbstractListModel): else: self._logger.debug('unhandled event %s: %s' % (event, repr(args))) + def on_destroy(self): + unregister_callback(self.on_network) def rowCount(self, index): return len(self.channels) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index b72dd2012..69d621cf2 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -7,15 +7,16 @@ import threading from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer from electrum.i18n import _ -from electrum.util import register_callback, Satoshis, format_time, parse_max_spend, InvalidPassword +from electrum.util import (register_callback, unregister_callback, + Satoshis, format_time, parse_max_spend, InvalidPassword) from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import StorageEncryptionVersion from electrum import bitcoin from electrum.transaction import PartialTxOutput -from electrum.invoices import (Invoice, InvoiceError, - PR_DEFAULT_EXPIRATION_WHEN_CREATING, PR_PAID, - PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED) +from electrum.invoices import (Invoice, InvoiceError, + PR_DEFAULT_EXPIRATION_WHEN_CREATING, PR_PAID, + PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED) from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel from .qetransactionlistmodel import QETransactionListModel @@ -86,6 +87,7 @@ class QEWallet(AuthMixin, QObject): # methods of this class only, and specifically not be # partials, lambdas or methods of subobjects. Hence... register_callback(self.on_network, interests) + self.destroyed.connect(lambda: self.on_destroy()) @pyqtProperty(bool, notify=isUptodateChanged) def isUptodate(self): @@ -154,6 +156,8 @@ class QEWallet(AuthMixin, QObject): else: self._logger.debug('unhandled event: %s %s' % (event, str(args))) + def on_destroy(self): + unregister_callback(self.on_network) def add_tx_notification(self, tx): self._logger.debug('new transaction event') From 71cd99637952f0d6249fa64c9135d94416666553 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 29 Jun 2022 00:14:18 +0200 Subject: [PATCH 186/218] InvoiceDialog: add balance & expired checks on invoices, add a few ln invoice fields to show --- electrum/gui/qml/components/InvoiceDialog.qml | 48 ++++++++++++++++- electrum/gui/qml/qeinvoice.py | 51 ++++++++++++++++--- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 394ffcd42..48b8daee5 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -47,7 +47,6 @@ Dialog { RowLayout { Layout.fillWidth: true Image { - //Layout.rowSpan: 2 Layout.preferredWidth: constants.iconSizeSmall Layout.preferredHeight: constants.iconSizeSmall source: invoice.invoiceType == Invoice.LightningInvoice @@ -117,6 +116,45 @@ Dialog { wrapMode: Text.Wrap } + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + text: qsTr('Remote Pubkey') + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + Layout.fillWidth: true + text: invoice.lnprops.pubkey + font.family: FixedFont + wrapMode: Text.Wrap + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + text: qsTr('Route via (t)') + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + Layout.fillWidth: true + text: invoice.lnprops.t + font.family: FixedFont + wrapMode: Text.Wrap + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + text: qsTr('Route via (r)') + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + Layout.fillWidth: true + text: invoice.lnprops.r + font.family: FixedFont + wrapMode: Text.Wrap + } + Label { text: qsTr('Status') } @@ -134,6 +172,13 @@ Dialog { Item { Layout.preferredHeight: constants.paddingLarge; Layout.preferredWidth: 1 } + InfoTextArea { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + visible: invoice.userinfo + text: invoice.userinfo + } + RowLayout { Layout.columnSpan: 2 Layout.alignment: Qt.AlignHCenter @@ -178,7 +223,6 @@ Dialog { } Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } - } Component.onCompleted: { diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index ef21ad254..08e90531d 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -9,8 +9,9 @@ from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, Invoi maybe_extract_bolt11_invoice) from electrum.invoices import Invoice from electrum.invoices import (PR_UNPAID,PR_EXPIRED,PR_UNKNOWN,PR_PAID,PR_INFLIGHT, - PR_FAILED,PR_ROUTING,PR_UNCONFIRMED) + PR_FAILED,PR_ROUTING,PR_UNCONFIRMED,LN_EXPIRY_NEVER) from electrum.transaction import PartialTxOutput +from electrum.lnaddr import lndecode from electrum import bitcoin from .qewallet import QEWallet @@ -91,6 +92,17 @@ class QEInvoice(QObject): self._key = key self.keyChanged.emit() + userinfoChanged = pyqtSignal() + @pyqtProperty(str, notify=userinfoChanged) + def userinfo(self): + return self._userinfo + + @userinfo.setter + def userinfo(self, userinfo): + if self._userinfo != userinfo: + self._userinfo = userinfo + self.userinfoChanged.emit() + def get_max_spendable_onchain(self): c, u, x = self._wallet.wallet.get_balance() #TODO determine real max @@ -105,6 +117,7 @@ class QEInvoiceParser(QEInvoice): _recipient = '' _effectiveInvoice = None _amount = QEAmount() + _userinfo = '' invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal() @@ -180,6 +193,19 @@ class QEInvoiceParser(QEInvoice): def address(self): return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' + @pyqtProperty('QVariantMap', notify=invoiceChanged) + def lnprops(self): + if not self.invoiceType == QEInvoice.Type.LightningInvoice: + return {} + lnaddr = self._effectiveInvoice._lnaddr + self._logger.debug(str(lnaddr)) + self._logger.debug(str(lnaddr.get_routing_info('t'))) + return { + 'pubkey': lnaddr.pubkey.serialize().hex(), + 't': lnaddr.get_routing_info('t')[0][0].hex(), + 'r': lnaddr.get_routing_info('r')[0][0][0].hex() + } + @pyqtSlot() def clear(self): self.recipient = '' @@ -187,12 +213,14 @@ class QEInvoiceParser(QEInvoice): self._bip21 = None self.canSave = False self.canPay = False + self.userinfo = '' self.invoiceChanged.emit() # don't parse the recipient string, but init qeinvoice from an invoice key # this should not emit validation signals @pyqtSlot(str) def initFromKey(self, key): + self.clear() invoice = self._wallet.wallet.get_invoice(key) self._logger.debug(repr(invoice)) if invoice: @@ -209,15 +237,26 @@ class QEInvoiceParser(QEInvoice): self.canSave = True + self.determine_can_pay() + + self.invoiceChanged.emit() + self.statusChanged.emit() + + def determine_can_pay(self): if self.invoiceType == QEInvoice.Type.LightningInvoice: - if self.get_max_spendable_lightning() >= self.amount.satsInt: - self.canPay = True + if self.status == PR_UNPAID: + if self.get_max_spendable_lightning() >= self.amount.satsInt: + self.canPay = True + else: + self.userinfo = _('Can\'t pay, insufficient balance') + else: + self.userinfo = _('Can\'t pay, invoice is expired') elif self.invoiceType == QEInvoice.Type.OnchainInvoice: if self.get_max_spendable_onchain() >= self.amount.satsInt: self.canPay = True + else: + self.userinfo = _('Can\'t pay, insufficient balance') - self.invoiceChanged.emit() - self.statusChanged.emit() def get_max_spendable_lightning(self): return self._wallet.wallet.lnworker.num_sats_can_send() @@ -225,7 +264,7 @@ class QEInvoiceParser(QEInvoice): def setValidAddressOnly(self): self._logger.debug('setValidAddressOnly') self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) - self._effectiveInvoice = None ###TODO + self._effectiveInvoice = None self.invoiceChanged.emit() def setValidOnchainInvoice(self, invoice: Invoice): From e69fc739ca0fa1f544b70a386a8711f5c105d56c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 29 Jun 2022 00:17:17 +0200 Subject: [PATCH 187/218] add initial channel close dialog --- .../gui/qml/components/ChannelDetails.qml | 21 ++- .../gui/qml/components/CloseChannelDialog.qml | 130 ++++++++++++++++++ electrum/gui/qml/qechanneldetails.py | 49 ++++++- electrum/gui/qml/qechannellistmodel.py | 2 +- 4 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 electrum/gui/qml/components/CloseChannelDialog.qml diff --git a/electrum/gui/qml/components/ChannelDetails.qml b/electrum/gui/qml/components/ChannelDetails.qml index 265cae031..1b0e333f5 100644 --- a/electrum/gui/qml/components/ChannelDetails.qml +++ b/electrum/gui/qml/components/ChannelDetails.qml @@ -31,17 +31,11 @@ Pane { icon.color: 'transparent' action: Action { text: qsTr('Close channel'); - enabled: false - onTriggered: {} - //icon.source: '../../icons/wallet.png' - } - } - MenuItem { - icon.color: 'transparent' - action: Action { - text: qsTr('Force-close'); - enabled: false - onTriggered: {} + enabled: channeldetails.canClose + onTriggered: { + var dialog = closechannel.createObject(root, { 'channelid': channelid }) + dialog.open() + } //icon.source: '../../icons/wallet.png' } } @@ -245,4 +239,9 @@ Pane { id: share GenericShareDialog {} } + + Component { + id: closechannel + CloseChannelDialog {} + } } diff --git a/electrum/gui/qml/components/CloseChannelDialog.qml b/electrum/gui/qml/components/CloseChannelDialog.qml new file mode 100644 index 000000000..2bd2c34aa --- /dev/null +++ b/electrum/gui/qml/components/CloseChannelDialog.qml @@ -0,0 +1,130 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: dialog + width: parent.width + height: parent.height + + property string channelid + + title: qsTr('Close Channel') + standardButtons: closing ? 0 : Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + property bool closing: false + + closePolicy: Popup.NoAutoClose + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Label { + text: qsTr('Channel name') + color: Material.accentColor + } + + Label { + text: channeldetails.name + } + + Label { + text: qsTr('Short channel ID') + color: Material.accentColor + } + + Label { + text: channeldetails.short_cid + } + + InfoTextArea { + Layout.columnSpan: 2 + text: qsTr(channeldetails.message_force_close) + } + + ColumnLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + + ButtonGroup { + id: closetypegroup + } + + RadioButton { + ButtonGroup.group: closetypegroup + property string closetype: 'cooperative' + checked: true + enabled: !closing && channeldetails.canCoopClose + text: qsTr('Cooperative close') + } + RadioButton { + ButtonGroup.group: closetypegroup + property string closetype: 'force' + enabled: !closing && channeldetails.canForceClose + text: qsTr('Request Force-close') + } + } + + Button { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr('Close') + enabled: !closing + onClicked: { + closing = true + channeldetails.close_channel(closetypegroup.checkedButton.closetype) + } + + } + + ColumnLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + Label { + id: errorText + visible: !closing && errorText + wrapMode: Text.Wrap + Layout.preferredWidth: layout.width + } + Label { + text: qsTr('Closing...') + visible: closing + } + BusyIndicator { + visible: closing + } + } + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + + } + + ChannelDetails { + id: channeldetails + wallet: Daemon.currentWallet + channelid: dialog.channelid + + onChannelCloseSuccess: { + closing = false + dialog.close() + } + + onChannelCloseFailed: { + closing = false + errorText.text = message + } + } + +} diff --git a/electrum/gui/qml/qechanneldetails.py b/electrum/gui/qml/qechanneldetails.py index 96ff41b21..f3080ee3a 100644 --- a/electrum/gui/qml/qechanneldetails.py +++ b/electrum/gui/qml/qechanneldetails.py @@ -1,8 +1,13 @@ +import asyncio + from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS +from electrum.i18n import _ +from electrum.gui import messages from electrum.logging import get_logger from electrum.util import register_callback, unregister_callback from electrum.lnutil import LOCAL, REMOTE +from electrum.lnchannel import ChanCloseOption from .qewallet import QEWallet from .qetypes import QEAmount @@ -15,6 +20,8 @@ class QEChannelDetails(QObject): _channel = None channelChanged = pyqtSignal() + channelCloseSuccess = pyqtSignal() + channelCloseFailed = pyqtSignal([str], arguments=['message']) def __init__(self, parent=None): super().__init__(parent) @@ -57,7 +64,7 @@ class QEChannelDetails(QObject): def load(self): lnchannels = self._wallet.wallet.lnworker.channels for channel in lnchannels.values(): - self._logger.debug('%s == %s ?' % (self._channelid, channel.channel_id)) + #self._logger.debug('%s == %s ?' % (self._channelid, channel.channel_id)) if self._channelid == channel.channel_id.hex(): self._channel = channel self.channelChanged.emit() @@ -115,22 +122,54 @@ class QEChannelDetails(QObject): def isOpen(self): return self._channel.is_open() + @pyqtProperty(bool, notify=channelChanged) + def canClose(self): + return self.canCoopClose or self.canForceClose + + @pyqtProperty(bool, notify=channelChanged) + def canCoopClose(self): + return ChanCloseOption.COOP_CLOSE in self._channel.get_close_options() + + @pyqtProperty(bool, notify=channelChanged) + def canForceClose(self): + return ChanCloseOption.LOCAL_FCLOSE in self._channel.get_close_options() + + @pyqtProperty(str, notify=channelChanged) + def message_force_close(self, notify=channelChanged): + return _(messages.MSG_REQUEST_FORCE_CLOSE) + @pyqtSlot() def freezeForSending(self): lnworker = self._channel.lnworker if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id): - #self.is_frozen_for_sending = not self.is_frozen_for_sending self._channel.set_frozen_for_sending(not self.frozenForSending) self.channelChanged.emit() else: - self._logger.debug('TODO: messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP') + self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP) @pyqtSlot() def freezeForReceiving(self): lnworker = self._channel.lnworker if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id): - #self.is_frozen_for_sending = not self.is_frozen_for_sending self._channel.set_frozen_for_receiving(not self.frozenForReceiving) self.channelChanged.emit() else: - self._logger.debug('TODO: messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP') + self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP) + + # this method assumes the qobject is not destroyed before the close either fails or succeeds + @pyqtSlot(str) + def close_channel(self, closetype): + async def do_close(closetype, channel_id): + try: + if closetype == 'force': + await self._wallet.wallet.lnworker.request_force_close(channel_id) + else: + await self._wallet.wallet.lnworker.close_channel(channel_id) + self.channelCloseSuccess.emit() + except Exception as e: + self._logger.exception("Could not close channel: " + repr(e)) + self.channelCloseFailed.emit(_('Could not close channel: ') + repr(e)) + + loop = self._wallet.wallet.network.asyncio_loop + coro = do_close(closetype, self._channel.channel_id) + asyncio.run_coroutine_threadsafe(coro, loop) diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 50fd55dd2..ce6c7964f 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -117,7 +117,7 @@ class QEChannelListModel(QAbstractListModel): def on_channel_updated(self, channel): i = 0 for c in self.channels: - if c['cid'] == channel.channel_id: + if c['cid'] == channel.channel_id.hex(): self.do_update(i,channel) break i = i + 1 From 2907698c172dfd89d8bc44e5e177127e6ca53e77 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 29 Jun 2022 11:42:19 +0200 Subject: [PATCH 188/218] support for MAX amounts --- .../gui/qml/components/ConfirmTxDialog.qml | 20 ++++++++--- electrum/gui/qml/components/Send.qml | 3 +- electrum/gui/qml/qechannelopener.py | 7 ++-- electrum/gui/qml/qetxfinalizer.py | 35 +++++++++++++------ 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 2123e2749..a6394bf1a 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -35,6 +35,13 @@ Dialog { color: "#aa000000" } + function updateAmountText() { + btcValue.text = Config.formatSats(finalizer.effectiveAmount, false) + fiatValue.text = Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(finalizer.effectiveAmount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + GridLayout { id: layout width: parent.width @@ -56,8 +63,8 @@ Dialog { RowLayout { Layout.fillWidth: true Label { + id: btcValue font.bold: true - text: Config.formatSats(satoshis, false) } Label { @@ -68,11 +75,16 @@ Dialog { Label { id: fiatValue Layout.fillWidth: true - text: Daemon.fx.enabled - ? '(' + Daemon.fx.fiatValue(satoshis, false) + ' ' + Daemon.fx.fiatCurrency + ')' - : '' font.pixelSize: constants.fontSizeMedium } + + Component.onCompleted: updateAmountText() + Connections { + target: finalizer + function onEffectiveAmountChanged() { + updateAmountText() + } + } } Label { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 5dcfde0d8..77f605f58 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -79,6 +79,7 @@ Pane { BtcField { id: amount fiatfield: amountFiat + enabled: !is_max.checked Layout.preferredWidth: parent.width /3 onTextChanged: { userEnteredPayment.amount = is_max.checked ? MAX : Config.unitsToSats(amount.text) @@ -107,6 +108,7 @@ Pane { id: amountFiat btcfield: amount visible: Daemon.fx.enabled + enabled: !is_max.checked Layout.preferredWidth: parent.width /3 } @@ -243,7 +245,6 @@ Pane { title: qsTr('Confirm Payment') finalizer: TxFinalizer { wallet: Daemon.currentWallet - onAmountChanged: console.log(amount.satsInt) } } } diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index cadd54855..e6e185171 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -127,17 +127,20 @@ class QEChannelOpener(QObject): return amount = '!' if self._amount.isMax else self._amount.satsInt + self._logger.debug('amount = %s' % str(amount)) + coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True) - mktx = lambda: lnworker.mktx_for_open_channel( + mktx = lambda amt: lnworker.mktx_for_open_channel( coins=coins, - funding_sat=amount, + funding_sat=amt, node_id=self._peer.pubkey, fee_est=None) acpt = lambda tx: self.do_open_channel(tx, str(self._peer), None) self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt) + self._finalizer.amount = self._amount self._finalizer.wallet = self._wallet self.finalizerChanged.emit() diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 25f2c2d95..4467ef1fa 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -21,6 +21,7 @@ class QETxFinalizer(QObject): _address = '' _amount = QEAmount() + _effectiveAmount = QEAmount() _fee = QEAmount() _feeRate = '' _wallet = None @@ -75,6 +76,11 @@ class QETxFinalizer(QObject): self._amount = amount self.amountChanged.emit() + effectiveAmountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=effectiveAmountChanged) + def effectiveAmount(self): + return self._effectiveAmount + feeChanged = pyqtSignal() @pyqtProperty(QEAmount, notify=feeChanged) def fee(self): @@ -208,28 +214,34 @@ class QETxFinalizer(QObject): self.update() @profiler - def make_tx(self): + def make_tx(self, amount): + self._logger.debug('make_tx amount = %s' % str(amount)) + if self.f_make_tx: - tx = self.f_make_tx() - return tx + tx = self.f_make_tx(amount) + else: + # default impl + coins = self._wallet.wallet.get_spendable_coins(None) + outputs = [PartialTxOutput.from_address_and_value(self.address, amount)] + tx = self._wallet.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None,rbf=self._rbf) - # default impl - coins = self._wallet.wallet.get_spendable_coins(None) - outputs = [PartialTxOutput.from_address_and_value(self.address, self._amount.satsInt)] - tx = self._wallet.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None,rbf=self._rbf) self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) - self._logger.debug(repr(tx.outputs())) + outputs = [] for o in tx.outputs(): - outputs.append(o.to_json()) + outputs.append({ + 'address': o.get_ui_address_str(), + 'value_sats': o.value + }) self.outputs = outputs + return tx @pyqtSlot() def update(self): try: # make unsigned transaction - tx = self.make_tx() + tx = self.make_tx(amount = '!' if self._amount.isMax else self._amount.satsInt) except NotEnoughFunds: self.warning = _("Not enough funds") self._valid = False @@ -246,6 +258,9 @@ class QETxFinalizer(QObject): amount = self._amount.satsInt if not self._amount.isMax else tx.output_value() + self._effectiveAmount = QEAmount(amount_sat=amount) + self.effectiveAmountChanged.emit() + tx_size = tx.estimated_size() fee = tx.get_fee() feerate = Decimal(fee) / tx_size # sat/byte From 7a26ab259cafac32baacb17dc15f0203d118b14e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 29 Jun 2022 13:35:51 +0200 Subject: [PATCH 189/218] update channel listmodel when channel opened --- electrum/gui/qml/components/OpenChannel.qml | 4 +++- electrum/gui/qml/qechannellistmodel.py | 12 ++++++++++++ electrum/gui/qml/qechannelopener.py | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml index 571895dfa..c996858cc 100644 --- a/electrum/gui/qml/components/OpenChannel.qml +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -176,9 +176,11 @@ Pane { onChannelOpenSuccess: { var message = 'success!' if (!has_backup) - message = message = ' (but no backup. TODO: show QR)' + message = message + ' (but no backup. TODO: show QR)' var dialog = app.messageDialog.createObject(root, { 'text': message }) dialog.open() + channelopener.wallet.channelModel.new_channel(cid) + app.stack.pop() } } diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index ce6c7964f..82c4103a2 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -129,3 +129,15 @@ class QEChannelListModel(QAbstractListModel): mi = self.createIndex(modelindex, 0) self.dataChanged.emit(mi, mi, self._ROLE_KEYS) + + @pyqtSlot(str) + def new_channel(self, cid): + lnchannels = self.wallet.lnworker.channels + for channel in lnchannels.values(): + self._logger.debug(repr(channel)) + if cid == channel.channel_id.hex(): + item = self.channel_to_model(channel) + self._logger.debug(item) + self.beginInsertRows(QModelIndex(), 0, 0) + self.channels.insert(0,item) + self.endInsertRows() diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index e6e185171..99fef56d1 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -25,7 +25,7 @@ class QEChannelOpener(QObject): validationError = pyqtSignal([str,str], arguments=['code','message']) conflictingBackup = pyqtSignal([str], arguments=['message']) channelOpenError = pyqtSignal([str], arguments=['message']) - channelOpenSuccess = pyqtSignal([bool], arguments=['has_backup']) + channelOpenSuccess = pyqtSignal([str,bool], arguments=['cid','has_backup']) dataChanged = pyqtSignal() # generic notify signal @@ -160,7 +160,7 @@ class QEChannelOpener(QObject): self.channelOpenError.emit(_('Problem opening channel: ') + '\n' + repr(e)) return - self.channelOpenSuccess.emit(chan.has_onchain_backup()) + self.channelOpenSuccess.emit(chan.channel_id.hex(), chan.has_onchain_backup()) # TODO: it would be nice to show this before broadcasting #if chan.has_onchain_backup(): From a44f8d9b3b40ba7f769984a4bd222aa21da97418 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 30 Jun 2022 10:12:04 +0200 Subject: [PATCH 190/218] create new wallet name suggestion and pre-select and focus the textfield --- .../gui/qml/components/wizard/WCWalletName.qml | 9 +++++++++ electrum/gui/qml/qedaemon.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/electrum/gui/qml/components/wizard/WCWalletName.qml b/electrum/gui/qml/components/wizard/WCWalletName.qml index c83d25fe4..6fd14dcfa 100644 --- a/electrum/gui/qml/components/wizard/WCWalletName.qml +++ b/electrum/gui/qml/components/wizard/WCWalletName.qml @@ -1,6 +1,9 @@ +import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 +import org.electrum 1.0 + WizardComponent { valid: wallet_name.text.length > 0 @@ -13,6 +16,12 @@ WizardComponent { Label { text: qsTr('Wallet name') } TextField { id: wallet_name + focus: true + text: Daemon.suggestWalletName() } } + + Component.onCompleted: { + wallet_name.selectAll() + } } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index c531a5df4..958bd88ca 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -85,6 +85,12 @@ class QEAvailableWalletListModel(QEWalletListModel): wallet = self.daemon.get_wallet(path) self.add_wallet(wallet_path = path, wallet = wallet) + def wallet_name_exists(self, name): + for wallet_name, wallet_path, wallet in self.wallets: + if name == wallet_name: + return True + return False + class QEDaemon(AuthMixin, QObject): def __init__(self, daemon, parent=None): super().__init__(parent) @@ -181,3 +187,11 @@ class QEDaemon(AuthMixin, QObject): @pyqtProperty(QEFX, notify=fxChanged) def fx(self): return self.qefx + + @pyqtSlot(result=str) + def suggestWalletName(self): + i = 1 + while self.availableWallets.wallet_name_exists(f'wallet_{i}'): + i = i + 1 + return f'wallet_{i}' + From b2fafcb428f76b700ad46b7955a9d378b38ade56 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 30 Jun 2022 15:06:45 +0200 Subject: [PATCH 191/218] add initial submarine swap functionality --- electrum/gui/qml/components/Channels.qml | 23 +- electrum/gui/qml/components/Swap.qml | 205 ++++++++++++++ electrum/gui/qml/components/main.qml | 7 + electrum/gui/qml/qeapp.py | 3 +- electrum/gui/qml/qeswaphelper.py | 324 +++++++++++++++++++++++ electrum/gui/qml/qewallet.py | 7 +- 6 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 electrum/gui/qml/components/Swap.qml create mode 100644 electrum/gui/qml/qeswaphelper.py diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index a401e0195..2d9e2f169 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -1,6 +1,6 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.0 +import QtQuick.Controls 2.3 import QtQuick.Controls.Material 2.0 import org.electrum 1.0 @@ -8,8 +8,25 @@ import org.electrum 1.0 import "controls" Pane { + id: root property string title: qsTr("Lightning Channels") + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Swap'); + enabled: Daemon.currentWallet.lightningCanSend.satsInt > 0 || Daemon.currentWallet.lightningCanReceive.satInt > 0 + onTriggered: { + var dialog = swapDialog.createObject(root) + dialog.open() + } + icon.source: '../../icons/status_waiting.png' + } + } + } + ColumnLayout { id: layout width: parent.width @@ -129,4 +146,8 @@ Pane { } + Component { + id: swapDialog + Swap {} + } } diff --git a/electrum/gui/qml/components/Swap.qml b/electrum/gui/qml/components/Swap.qml new file mode 100644 index 000000000..9a6532256 --- /dev/null +++ b/electrum/gui/qml/components/Swap.qml @@ -0,0 +1,205 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: root + + width: parent.width + height: parent.height + + title: qsTr('Lightning Swap') + standardButtons: Dialog.Cancel + + 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('You send') + color: Material.accentColor + } + + RowLayout { + Label { + id: tosend + text: Config.formatSats(swaphelper.tosend) + font.family: FixedFont + visible: swaphelper.valid + } + Label { + text: Config.baseUnit + color: Material.accentColor + visible: swaphelper.valid + } + Label { + text: swaphelper.isReverse ? qsTr('(offchain)') : qsTr('(onchain)') + visible: swaphelper.valid + } + } + + Label { + text: qsTr('You receive') + color: Material.accentColor + } + + RowLayout { + Layout.fillWidth: true + Label { + id: toreceive + text: Config.formatSats(swaphelper.toreceive) + font.family: FixedFont + visible: swaphelper.valid + } + Label { + text: Config.baseUnit + color: Material.accentColor + visible: swaphelper.valid + } + Label { + text: swaphelper.isReverse ? qsTr('(onchain)') : qsTr('(offchain)') + visible: swaphelper.valid + } + } + + Label { + text: qsTr('Server fee') + color: Material.accentColor + } + + RowLayout { + Label { + text: swaphelper.serverfeeperc + } + Label { + text: Config.formatSats(swaphelper.serverfee) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Mining fee') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatSats(swaphelper.miningfee) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Slider { + id: swapslider + Layout.columnSpan: 2 + Layout.preferredWidth: 2/3 * layout.width + Layout.alignment: Qt.AlignHCenter + + from: swaphelper.rangeMin + to: swaphelper.rangeMax + + onValueChanged: { + if (activeFocus) + swaphelper.sliderPos = value + } + Component.onCompleted: { + value = swaphelper.sliderPos + } + Connections { + target: swaphelper + function onSliderPosChanged() { + swapslider.value = swaphelper.sliderPos + } + } + } + + InfoTextArea { + Layout.columnSpan: 2 + visible: swaphelper.userinfo != '' + text: swaphelper.userinfo + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Button { + Layout.alignment: Qt.AlignHCenter + Layout.columnSpan: 2 + text: qsTr('Ok') + enabled: swaphelper.valid + onClicked: swaphelper.executeSwap() + } + + Item { Layout.fillHeight: true; Layout.preferredWidth: 1; Layout.columnSpan: 2 } + } + + SwapHelper { + id: swaphelper + wallet: Daemon.currentWallet + onError: { + var dialog = app.messageDialog.createObject(root, {'text': message}) + dialog.open() + } + onConfirm: { + var dialog = app.messageDialog.createObject(app, {'text': message, 'yesno': true}) + dialog.yesClicked.connect(function() { + dialog.close() + swaphelper.executeSwap(true) + root.close() + }) + dialog.open() + } + onAuthRequired: { // TODO: don't replicate this code + if (swaphelper.wallet.verify_password('')) { + // wallet has no password + console.log('wallet has no password, proceeding') + swaphelper.authProceed() + } else { + var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) + dialog.accepted.connect(function() { + if (swaphelper.wallet.verify_password(dialog.password)) { + swaphelper.wallet.authProceed() + } else { + swaphelper.wallet.authCancel() + } + }) + dialog.rejected.connect(function() { + swaphelper.wallet.authCancel() + }) + dialog.open() + } + } + } +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 9320857d4..b820e4f4a 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -251,6 +251,13 @@ ApplicationWindow dialog.open() } } + // TODO: add to notification queue instead of barging through + function onPaymentSucceeded(key) { + notificationPopup.show(qsTr('Payment Succeeded')) + } + function onPaymentFailed(key, reason) { + notificationPopup.show(qsTr('Payment Failed') + ': ' + reason) + } } Connections { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index eba9be641..5dfc5fa71 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -26,6 +26,7 @@ from .qetxdetails import QETxDetails from .qechannelopener import QEChannelOpener from .qelnpaymentdetails import QELnPaymentDetails from .qechanneldetails import QEChannelDetails +from .qeswaphelper import QESwapHelper notification = None @@ -148,12 +149,12 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser') qmlRegisterType(QEUserEnteredPayment, 'org.electrum', 1, 0, 'UserEnteredPayment') - qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails') qmlRegisterType(QEChannelDetails, 'org.electrum', 1, 0, 'ChannelDetails') + qmlRegisterType(QESwapHelper, 'org.electrum', 1, 0, 'SwapHelper') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py new file mode 100644 index 000000000..e7cc22ae3 --- /dev/null +++ b/electrum/gui/qml/qeswaphelper.py @@ -0,0 +1,324 @@ +import asyncio +from typing import TYPE_CHECKING, Optional, Union + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.lnutil import ln_dummy_address +from electrum.transaction import PartialTxOutput +from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler + +from .qewallet import QEWallet +from .qetypes import QEAmount +from .auth import AuthMixin, auth_protect + +class QESwapHelper(AuthMixin, QObject): + _logger = get_logger(__name__) + + _wallet = None + _sliderPos = 0 + _rangeMin = 0 + _rangeMax = 0 + _tx = None + _valid = False + _userinfo = '' + _tosend = QEAmount() + _toreceive = QEAmount() + _serverfeeperc = '' + _serverfee = QEAmount() + _miningfee = QEAmount() + _isReverse = False + + _send_amount = 0 + _receive_amount = 0 + + error = pyqtSignal([str], arguments=['message']) + confirm = pyqtSignal([str], arguments=['message']) + + def __init__(self, parent=None): + super().__init__(parent) + + 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.init_swap_slider_range() + self.walletChanged.emit() + + sliderPosChanged = pyqtSignal() + @pyqtProperty(float, notify=sliderPosChanged) + def sliderPos(self): + return self._sliderPos + + @sliderPos.setter + def sliderPos(self, sliderPos): + if self._sliderPos != sliderPos: + self._sliderPos = sliderPos + self.swap_slider_moved() + self.sliderPosChanged.emit() + + rangeMinChanged = pyqtSignal() + @pyqtProperty(float, notify=rangeMinChanged) + def rangeMin(self): + return self._rangeMin + + @rangeMin.setter + def rangeMin(self, rangeMin): + if self._rangeMin != rangeMin: + self._rangeMin = rangeMin + self.rangeMinChanged.emit() + + rangeMaxChanged = pyqtSignal() + @pyqtProperty(float, notify=rangeMaxChanged) + def rangeMax(self): + return self._rangeMax + + @rangeMax.setter + def rangeMax(self, rangeMax): + if self._rangeMax != rangeMax: + self._rangeMax = rangeMax + self.rangeMaxChanged.emit() + + validChanged = pyqtSignal() + @pyqtProperty(bool, notify=validChanged) + def valid(self): + return self._valid + + @valid.setter + def valid(self, valid): + if self._valid != valid: + self._valid = valid + self.validChanged.emit() + + userinfoChanged = pyqtSignal() + @pyqtProperty(str, notify=userinfoChanged) + def userinfo(self): + return self._userinfo + + @userinfo.setter + def userinfo(self, userinfo): + if self._userinfo != userinfo: + self._userinfo = userinfo + self.userinfoChanged.emit() + + tosendChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=tosendChanged) + def tosend(self): + return self._tosend + + @tosend.setter + def tosend(self, tosend): + if self._tosend != tosend: + self._tosend = tosend + self.tosendChanged.emit() + + toreceiveChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=toreceiveChanged) + def toreceive(self): + return self._toreceive + + @toreceive.setter + def toreceive(self, toreceive): + if self._toreceive != toreceive: + self._toreceive = toreceive + self.toreceiveChanged.emit() + + serverfeeChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=serverfeeChanged) + def serverfee(self): + return self._serverfee + + @serverfee.setter + def serverfee(self, serverfee): + if self._serverfee != serverfee: + self._serverfee = serverfee + self.serverfeeChanged.emit() + + serverfeepercChanged = pyqtSignal() + @pyqtProperty(str, notify=serverfeepercChanged) + def serverfeeperc(self): + return self._serverfeeperc + + @serverfeeperc.setter + def serverfeeperc(self, serverfeeperc): + if self._serverfeeperc != serverfeeperc: + self._serverfeeperc = serverfeeperc + self.serverfeepercChanged.emit() + + miningfeeChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=miningfeeChanged) + def miningfee(self): + return self._miningfee + + @miningfee.setter + def miningfee(self, miningfee): + if self._miningfee != miningfee: + self._miningfee = miningfee + self.miningfeeChanged.emit() + + isReverseChanged = pyqtSignal() + @pyqtProperty(bool, notify=isReverseChanged) + def isReverse(self): + return self._isReverse + + @isReverse.setter + def isReverse(self, isReverse): + if self._isReverse != isReverse: + self._isReverse = isReverse + self.isReverseChanged.emit() + + + def init_swap_slider_range(self): + lnworker = self._wallet.wallet.lnworker + swap_manager = lnworker.swap_manager + asyncio.run(swap_manager.get_pairs()) + """Sets the minimal and maximal amount that can be swapped for the swap + slider.""" + # tx is updated again afterwards with send_amount in case of normal swap + # this is just to estimate the maximal spendable onchain amount for HTLC + self.update_tx('!') + try: + max_onchain_spend = self._tx.output_value_for_address(ln_dummy_address()) + except AttributeError: # happens if there are no utxos + max_onchain_spend = 0 + reverse = int(min(lnworker.num_sats_can_send(), + swap_manager.get_max_amount())) + max_recv_amt_ln = int(swap_manager.num_sats_can_receive()) + max_recv_amt_oc = swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or float('inf') + forward = int(min(max_recv_amt_oc, + # maximally supported swap amount by provider + swap_manager.get_max_amount(), + max_onchain_spend)) + # we expect range to adjust the value of the swap slider to be in the + # correct range, i.e., to correct an overflow when reducing the limits + self._logger.debug(f'Slider range {-reverse} - {forward}') + self.rangeMin = -reverse + self.rangeMax = forward + + self.swap_slider_moved() + + @profiler + def update_tx(self, onchain_amount: Union[int, str]): + """Updates the transaction associated with a forward swap.""" + if onchain_amount is None: + self._tx = None + self.valid = False + return + outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), onchain_amount)] + coins = self._wallet.wallet.get_spendable_coins(None) + try: + self._tx = self._wallet.wallet.make_unsigned_transaction( + coins=coins, + outputs=outputs) + except (NotEnoughFunds, NoDynamicFeeEstimates): + self._tx = None + self.valid = False + + def swap_slider_moved(self): + position = int(self._sliderPos) + + swap_manager = self._wallet.wallet.lnworker.swap_manager + + # pay_amount and receive_amounts are always with fees already included + # so they reflect the net balance change after the swap + if position < 0: # reverse swap + self.userinfo = _('Adds Lightning receiving capacity.') + self.isReverse = True + + pay_amount = abs(position) + self._send_amount = pay_amount + self.tosend = QEAmount(amount_sat=pay_amount) + + receive_amount = swap_manager.get_recv_amount( + send_amount=pay_amount, is_reverse=True) + self._receive_amount = receive_amount + self.toreceive = QEAmount(amount_sat=receive_amount) + + # fee breakdown + self.serverfeeperc = f'{swap_manager.percentage:0.1f}%' + self.serverfee = QEAmount(amount_sat=swap_manager.lockup_fee) + self.miningfee = QEAmount(amount_sat=swap_manager.get_claim_fee()) + + else: # forward (normal) swap + self.userinfo = _('Adds Lightning sending capacity.') + self.isReverse = False + self._send_amount = position + + self.update_tx(self._send_amount) + # add lockup fees, but the swap amount is position + pay_amount = position + self._tx.get_fee() if self._tx else 0 + self.tosend = QEAmount(amount_sat=pay_amount) + + receive_amount = swap_manager.get_recv_amount(send_amount=position, is_reverse=False) + self._receive_amount = receive_amount + self.toreceive = QEAmount(amount_sat=receive_amount) + + # fee breakdown + self.serverfeeperc = f'{swap_manager.percentage:0.1f}%' + self.serverfee = QEAmount(amount_sat=swap_manager.normal_fee) + self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) + + if pay_amount and receive_amount: + self.valid = True + else: + # add more nuanced error reporting? + self.userinfo = _('Swap below minimal swap size, change the slider.') + self.valid = False + + def do_normal_swap(self, lightning_amount, onchain_amount, password): + assert self._tx + if lightning_amount is None or onchain_amount is None: + return + loop = self._wallet.wallet.network.asyncio_loop + coro = self._wallet.wallet.lnworker.swap_manager.normal_swap( + lightning_amount_sat=lightning_amount, + expected_onchain_amount_sat=onchain_amount, + password=password, + tx=self._tx, + ) + asyncio.run_coroutine_threadsafe(coro, loop) + + def do_reverse_swap(self, lightning_amount, onchain_amount, password): + if lightning_amount is None or onchain_amount is None: + return + swap_manager = self._wallet.wallet.lnworker.swap_manager + loop = self._wallet.wallet.network.asyncio_loop + coro = swap_manager.reverse_swap( + lightning_amount_sat=lightning_amount, + expected_onchain_amount_sat=onchain_amount + swap_manager.get_claim_fee(), + ) + asyncio.run_coroutine_threadsafe(coro, loop) + + @pyqtSlot() + @pyqtSlot(bool) + def executeSwap(self, confirm=False): + if not self._wallet.wallet.network: + self.error.emit(_("You are offline.")) + return + if confirm: + self._do_execute_swap() + return + + if self.isReverse: + self.confirm.emit(_('Do you want to do a reverse submarine swap?')) + else: + self.confirm.emit(_('Do you want to do a submarine swap? ' + 'You will need to wait for the swap transaction to confirm.' + )) + + @auth_protect + def _do_execute_swap(self): + if self.isReverse: + lightning_amount = self._send_amount + onchain_amount = self._receive_amount + self.do_reverse_swap(lightning_amount, onchain_amount, None) + else: + lightning_amount = self._receive_amount + onchain_amount = self._send_amount + self.do_normal_swap(lightning_amount, onchain_amount, None) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 69d621cf2..7de66c61e 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -120,8 +120,11 @@ class QEWallet(AuthMixin, QObject): self._logger.debug('invoice status update for key %s' % key) # FIXME event doesn't pass the new status, so we need to retrieve invoice = self.wallet.get_invoice(key) - status = self.wallet.get_invoice_status(invoice) - self.invoiceStatusChanged.emit(key, status) + if invoice: + status = self.wallet.get_invoice_status(invoice) + self.invoiceStatusChanged.emit(key, status) + else: + self._logger.debug(f'No invoice found for key {key}') elif event == 'new_transaction': wallet, tx = args if wallet == self.wallet: From 456b6048eafd441e4b40a48f70a5bcffa806f60b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 30 Jun 2022 21:07:08 +0200 Subject: [PATCH 192/218] properly count open channels, add open channel to hamburger menu --- electrum/gui/qml/components/Channels.qml | 11 ++++++++++- electrum/gui/qml/qechannellistmodel.py | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index 2d9e2f169..4962d10dd 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -25,6 +25,15 @@ Pane { icon.source: '../../icons/status_waiting.png' } } + MenuSeparator {} + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Open Channel'); + onTriggered: app.stack.push(Qt.resolvedUrl('OpenChannel.qml')) + icon.source: '../../icons/lightning.png' + } + } } ColumnLayout { @@ -39,7 +48,7 @@ Pane { Label { Layout.columnSpan: 2 - text: qsTr('You have %1 open channels').arg(listview.count) + text: qsTr('You have %1 open channels').arg(Daemon.currentWallet.channelModel.numOpenChannels) color: Material.accentColor } diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 82c4103a2..272c68d52 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -6,6 +6,7 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.logging import get_logger from electrum.util import Satoshis, register_callback, unregister_callback from electrum.lnutil import LOCAL, REMOTE +from electrum.lnchannel import ChannelState from .qetypes import QEAmount @@ -88,12 +89,18 @@ class QEChannelListModel(QAbstractListModel): item['node_alias'] = lnworker.get_node_alias(lnc.node_id) or lnc.node_id.hex() item['short_cid'] = lnc.short_id_for_GUI() item['state'] = lnc.get_state_for_GUI() + item['state_code'] = lnc.get_state() item['capacity'] = QEAmount(amount_sat=lnc.get_capacity()) item['can_send'] = QEAmount(amount_msat=lnc.available_to_spend(LOCAL)) item['can_receive'] = QEAmount(amount_msat=lnc.available_to_spend(REMOTE)) self._logger.debug(repr(item)) return item + numOpenChannelsChanged = pyqtSignal() + @pyqtProperty(int, notify=numOpenChannelsChanged) + def numOpenChannels(self): + return sum([1 if x['state_code'] == ChannelState.OPEN else 0 for x in self.channels]) + @pyqtSlot() def init_model(self): self._logger.debug('init_model') @@ -129,6 +136,7 @@ class QEChannelListModel(QAbstractListModel): mi = self.createIndex(modelindex, 0) self.dataChanged.emit(mi, mi, self._ROLE_KEYS) + self.numOpenChannelsChanged.emit() @pyqtSlot(str) def new_channel(self, cid): @@ -141,3 +149,4 @@ class QEChannelListModel(QAbstractListModel): self.beginInsertRows(QModelIndex(), 0, 0) self.channels.insert(0,item) self.endInsertRows() + self.numOpenChannelsChanged.emit() From db9e2ab311ac6041ac6d091f350f4924747cf96f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 30 Jun 2022 22:57:20 +0200 Subject: [PATCH 193/218] delete channel --- .../gui/qml/components/ChannelDetails.qml | 27 +++++++++++++++++-- electrum/gui/qml/qechanneldetails.py | 8 ++++++ electrum/gui/qml/qechannellistmodel.py | 13 ++++++++- electrum/gui/qml/qetransactionlistmodel.py | 1 + 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/components/ChannelDetails.qml b/electrum/gui/qml/components/ChannelDetails.qml index 1b0e333f5..2197ec5b1 100644 --- a/electrum/gui/qml/components/ChannelDetails.qml +++ b/electrum/gui/qml/components/ChannelDetails.qml @@ -24,7 +24,7 @@ Pane { text: qsTr('Backup'); enabled: false onTriggered: {} - //icon.source: '../../icons/wallet.png' + icon.source: '../../icons/file.png' } } MenuItem { @@ -36,7 +36,30 @@ Pane { var dialog = closechannel.createObject(root, { 'channelid': channelid }) dialog.open() } - //icon.source: '../../icons/wallet.png' + icon.source: '../../icons/closebutton.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Delete channel'); + enabled: channeldetails.canDelete + onTriggered: { + var dialog = app.messageDialog.createObject(root, + { + 'text': qsTr('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.'), + 'yesno': true + } + ) + dialog.yesClicked.connect(function() { + channeldetails.deleteChannel() + app.stack.pop() + Daemon.currentWallet.historyModel.init_model() // needed here? + Daemon.currentWallet.channelModel.remove_channel(channelid) + }) + dialog.open() + } + icon.source: '../../icons/delete.png' } } MenuItem { diff --git a/electrum/gui/qml/qechanneldetails.py b/electrum/gui/qml/qechanneldetails.py index f3080ee3a..527083fb4 100644 --- a/electrum/gui/qml/qechanneldetails.py +++ b/electrum/gui/qml/qechanneldetails.py @@ -134,6 +134,10 @@ class QEChannelDetails(QObject): def canForceClose(self): return ChanCloseOption.LOCAL_FCLOSE in self._channel.get_close_options() + @pyqtProperty(bool, notify=channelChanged) + def canDelete(self): + return self._channel.can_be_deleted() + @pyqtProperty(str, notify=channelChanged) def message_force_close(self, notify=channelChanged): return _(messages.MSG_REQUEST_FORCE_CLOSE) @@ -173,3 +177,7 @@ class QEChannelDetails(QObject): loop = self._wallet.wallet.network.asyncio_loop coro = do_close(closetype, self._channel.channel_id) asyncio.run_coroutine_threadsafe(coro, loop) + + @pyqtSlot() + def deleteChannel(self): + self._wallet.wallet.lnworker.remove_channel(self._channel.channel_id) diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 272c68d52..8d98063a9 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -149,4 +149,15 @@ class QEChannelListModel(QAbstractListModel): self.beginInsertRows(QModelIndex(), 0, 0) self.channels.insert(0,item) self.endInsertRows() - self.numOpenChannelsChanged.emit() + + @pyqtSlot(str) + def remove_channel(self, cid): + i = 0 + for channel in self.channels: + if cid == channel['cid']: + self._logger.debug(cid) + self.beginRemoveRows(QModelIndex(), i, i) + self.channels.remove(channel) + self.endRemoveRows() + return + i = i + 1 diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 6b18d6e3b..934a5ce31 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -102,6 +102,7 @@ class QETransactionListModel(QAbstractListModel): return date.strftime(dfmt[section]) # initial model data + @pyqtSlot() def init_model(self): history = self.wallet.get_full_history(onchain_domain=self.onchain_domain, include_lightning=self.include_lightning) From 982639919d1cc3d8804105b6d740ffe980400ddc Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 12:25:39 +0200 Subject: [PATCH 194/218] make sure historymodel is initialized --- electrum/gui/qml/qewallet.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 7de66c61e..c44cb1e29 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -129,11 +129,11 @@ class QEWallet(AuthMixin, QObject): wallet, tx = args if wallet == self.wallet: self.add_tx_notification(tx) - self._historyModel.init_model() # TODO: be less dramatic + self.historyModel.init_model() # TODO: be less dramatic elif event == 'verified': wallet, txid, info = args if wallet == self.wallet: - self._historyModel.update_tx(txid, info) + self.historyModel.update_tx(txid, info) elif event == 'wallet_updated': wallet, = args if wallet == self.wallet: @@ -151,7 +151,7 @@ class QEWallet(AuthMixin, QObject): wallet, key = args if wallet == self.wallet: self.paymentSucceeded.emit(key) - self._historyModel.init_model() # TODO: be less dramatic + self.historyModel.init_model() # TODO: be less dramatic elif event == 'payment_failed': wallet, key, reason = args if wallet == self.wallet: From c656b0231977409c19394dfce1c1ff6ec801944f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 13:09:08 +0200 Subject: [PATCH 195/218] update tip pthon-for-android qt5-wip --- contrib/android/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/android/Dockerfile b/contrib/android/Dockerfile index ab9636234..664f2ba27 100644 --- a/contrib/android/Dockerfile +++ b/contrib/android/Dockerfile @@ -170,7 +170,7 @@ RUN cd /opt \ && git remote add accumulator https://github.com/accumulator/python-for-android \ && git fetch --all \ # commit: from branch accumulator/qt5-wip - && git checkout "64490fc4a7b1f727f1f07c86e1bdc6b291ffc6da^{commit}" \ + && git checkout "fff4014747ab675bde3659f277095ac52ddf01f5^{commit}" \ && python3 -m pip install --no-build-isolation --no-dependencies --user -e . # build env vars From cbd4d2a2ae47091658688367828003dd9970add0 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 17:28:56 +0200 Subject: [PATCH 196/218] make rbf selection allowed configurable --- electrum/gui/qml/components/ConfirmTxDialog.qml | 1 + electrum/gui/qml/components/Send.qml | 1 + electrum/gui/qml/qechannelopener.py | 3 +++ electrum/gui/qml/qetxfinalizer.py | 14 ++++++++++++++ 4 files changed, 19 insertions(+) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index a6394bf1a..b19db825f 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -179,6 +179,7 @@ Dialog { text: qsTr('Replace-by-Fee') Layout.columnSpan: 2 checked: finalizer.rbf + visible: finalizer.canRbf } Rectangle { diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 77f605f58..96d0aa313 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -245,6 +245,7 @@ Pane { title: qsTr('Confirm Payment') finalizer: TxFinalizer { wallet: Daemon.currentWallet + canRbf: True } } } diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index 99fef56d1..8c5767380 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -140,11 +140,13 @@ class QEChannelOpener(QObject): acpt = lambda tx: self.do_open_channel(tx, str(self._peer), None) self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt) + self._finalizer.canRbf = False self._finalizer.amount = self._amount self._finalizer.wallet = self._wallet self.finalizerChanged.emit() def do_open_channel(self, funding_tx, conn_str, password): + self._logger.debug('opening channel') # read funding_sat from tx; converts '!' to int value funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) lnworker = self._wallet.wallet.lnworker @@ -160,6 +162,7 @@ class QEChannelOpener(QObject): self.channelOpenError.emit(_('Problem opening channel: ') + '\n' + repr(e)) return + self._logger.debug('opening channel succeeded') self.channelOpenSuccess.emit(chan.channel_id.hex(), chan.has_onchain_backup()) # TODO: it would be nice to show this before broadcasting diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 4467ef1fa..7a8c9fef9 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -32,6 +32,7 @@ class QETxFinalizer(QObject): _warning = '' _target = '' _rbf = False + _canRbf = False _outputs = [] config = None @@ -126,6 +127,19 @@ class QETxFinalizer(QObject): self.update() self.rbfChanged.emit() + canRbfChanged = pyqtSignal() + @pyqtProperty(bool, notify=canRbfChanged) + def canRbf(self): + return self._canRbf + + @canRbf.setter + def canRbf(self, canRbf): + if self._canRbf != canRbf: + self._canRbf = canRbf + self.canRbfChanged.emit() + if not canRbf and self.rbf: + self.rbf = False + outputsChanged = pyqtSignal() @pyqtProperty('QVariantList', notify=outputsChanged) def outputs(self): From f2857243cb68d7ba69cea75a98fabb43227f456c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 17:43:00 +0200 Subject: [PATCH 197/218] show 'press again to quit' message when pressing back on last page in the stack --- electrum/gui/qml/components/main.qml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index b820e4f4a..a2d048e3b 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -205,11 +205,23 @@ ApplicationWindow stack.pop() } else { // destroy most GUI components so that we don't dump so many null reference warnings on exit - app.header.visible = false - mainStackView.clear() + if (closeMsgTimer.running) { + app.header.visible = false + mainStackView.clear() + } else { + notificationPopup.show('Press Back again to exit') + closeMsgTimer.start() + close.accepted = false + } } } + Timer { + id: closeMsgTimer + interval: 5000 + repeat: false + } + Connections { target: Daemon function onWalletRequiresPassword() { From 6a1c39728fa9d68b9c6b2c5fc50d96b2b8c5b8b7 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 18:14:15 +0200 Subject: [PATCH 198/218] add 'spend unconfirmed' config option --- electrum/gui/qml/components/Preferences.qml | 21 ++++++++++++++++----- electrum/gui/qml/qeconfig.py | 10 ++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index e64ec97cc..7509b73a2 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -41,7 +41,7 @@ Pane { } } - CheckBox { + Switch { id: thousands Layout.columnSpan: 2 text: qsTr('Add thousands separators to bitcoin amounts') @@ -51,14 +51,14 @@ Pane { } } - CheckBox { + Switch { id: checkSoftware Layout.columnSpan: 2 text: qsTr('Automatically check for software updates') enabled: false } - CheckBox { + Switch { id: fiatEnable text: qsTr('Fiat Currency') onCheckedChanged: { @@ -77,12 +77,12 @@ Pane { } } - CheckBox { + Switch { id: historicRates text: qsTr('Historic rates') enabled: Daemon.fx.enabled Layout.columnSpan: 2 - onCheckStateChanged: { + onCheckedChanged: { if (activeFocus) Daemon.fx.historicRates = checked } @@ -106,6 +106,16 @@ Pane { } } + Switch { + id: spendUnconfirmed + text: qsTr('Spend unconfirmed') + Layout.columnSpan: 2 + onCheckedChanged: { + if (activeFocus) + Config.spendUnconfirmed = checked + } + } + Label { text: qsTr('Lightning Routing') } @@ -133,6 +143,7 @@ Pane { historicRates.checked = Daemon.fx.historicRates rateSources.currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) fiatEnable.checked = Daemon.fx.enabled + spendUnconfirmed.checked = Config.spendUnconfirmed lnRoutingType.currentIndex = Config.useGossip ? 0 : 1 } } diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index f8af476d6..88bb0ee7e 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -70,6 +70,16 @@ class QEConfig(QObject): self.config.amt_add_thousands_sep = checked self.thousandsSeparatorChanged.emit() + spendUnconfirmedChanged = pyqtSignal() + @pyqtProperty(bool, notify=spendUnconfirmedChanged) + def spendUnconfirmed(self): + return not self.config.get('confirmed_only', False) + + @spendUnconfirmed.setter + def spendUnconfirmed(self, checked): + self.config.set_key('confirmed_only', not checked, True) + self.spendUnconfirmedChanged.emit() + useGossipChanged = pyqtSignal() @pyqtProperty(bool, notify=useGossipChanged) def useGossip(self): From 6fecf5b962dc4d2652a0d4ef1c57dfd7d3897d72 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 18:35:34 +0200 Subject: [PATCH 199/218] disable new_channel call for now as channel is otherwise added twice --- electrum/gui/qml/components/OpenChannel.qml | 2 +- electrum/gui/qml/qechannellistmodel.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml index c996858cc..b050e509e 100644 --- a/electrum/gui/qml/components/OpenChannel.qml +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -179,7 +179,7 @@ Pane { message = message + ' (but no backup. TODO: show QR)' var dialog = app.messageDialog.createObject(root, { 'text': message }) dialog.open() - channelopener.wallet.channelModel.new_channel(cid) +// channelopener.wallet.channelModel.new_channel(cid) app.stack.pop() } } diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 8d98063a9..8003e69b9 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -140,6 +140,7 @@ class QEChannelListModel(QAbstractListModel): @pyqtSlot(str) def new_channel(self, cid): + self._logger.debug('new channel with cid ' % cid) lnchannels = self.wallet.lnworker.channels for channel in lnchannels.values(): self._logger.debug(repr(channel)) @@ -152,6 +153,7 @@ class QEChannelListModel(QAbstractListModel): @pyqtSlot(str) def remove_channel(self, cid): + self._logger.debug('remove channel with cid ' % cid) i = 0 for channel in self.channels: if cid == channel['cid']: From 7298dd0ab7b2d6b2f744d9716406407d721d6df9 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 18:46:45 +0200 Subject: [PATCH 200/218] clean up list of outputs in ConfirmTxDialog --- .../gui/qml/components/ConfirmTxDialog.qml | 32 +++++++++++++++---- electrum/gui/qml/qetxfinalizer.py | 3 +- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index b19db825f..e649fcaf2 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -196,17 +196,35 @@ Dialog { Repeater { model: finalizer.outputs - delegate: RowLayout { + delegate: TextHighlightPane { Layout.columnSpan: 2 - Label { - text: modelData.address - } - Label { - text: modelData.value_sats + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + RowLayout { + width: parent.width + Label { + text: modelData.address + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + color: modelData.is_mine ? constants.colorMine : Material.foreground + } + Label { + text: Config.formatSats(modelData.value_sats) + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + } + Label { + text: Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } } } } - + Rectangle { height: 1 Layout.fillWidth: true diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 7a8c9fef9..900abe3e2 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -245,7 +245,8 @@ class QETxFinalizer(QObject): for o in tx.outputs(): outputs.append({ 'address': o.get_ui_address_str(), - 'value_sats': o.value + 'value_sats': o.value, + 'is_mine': self._wallet.wallet.is_mine(o.get_ui_address_str()) }) self.outputs = outputs From dc50da6c62ab88e574fa16b437adc6ace803bc88 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 4 Jul 2022 19:12:27 +0200 Subject: [PATCH 201/218] channel freeze menu items only enabled when channel is open --- electrum/gui/qml/components/ChannelDetails.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/ChannelDetails.qml b/electrum/gui/qml/components/ChannelDetails.qml index 2197ec5b1..cea3b5b90 100644 --- a/electrum/gui/qml/components/ChannelDetails.qml +++ b/electrum/gui/qml/components/ChannelDetails.qml @@ -65,17 +65,17 @@ Pane { MenuItem { icon.color: 'transparent' action: Action { + enabled: channeldetails.isOpen text: channeldetails.frozenForSending ? qsTr('Unfreeze (for sending)') : qsTr('Freeze (for sending)') onTriggered: channeldetails.freezeForSending() - //icon.source: '../../icons/wallet.png' } } MenuItem { icon.color: 'transparent' action: Action { + enabled: channeldetails.isOpen text: channeldetails.frozenForReceiving ? qsTr('Unfreeze (for receiving)') : qsTr('Freeze (for receiving)') onTriggered: channeldetails.freezeForReceiving() - //icon.source: '../../icons/wallet.png' } } } From e289b8b46c9a78208ba512fc3259fc1bed409171 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 5 Jul 2022 18:29:05 +0200 Subject: [PATCH 202/218] move channels_updated event handling to ui thread, fix some debug statements --- electrum/gui/qml/components/OpenChannel.qml | 2 +- electrum/gui/qml/qechannellistmodel.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml index b050e509e..c996858cc 100644 --- a/electrum/gui/qml/components/OpenChannel.qml +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -179,7 +179,7 @@ Pane { message = message + ' (but no backup. TODO: show QR)' var dialog = app.messageDialog.createObject(root, { 'text': message }) dialog.open() -// channelopener.wallet.channelModel.new_channel(cid) + channelopener.wallet.channelModel.new_channel(cid) app.stack.pop() } } diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 8003e69b9..09a4f15f0 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -40,7 +40,7 @@ class QEChannelListModel(QAbstractListModel): self.destroyed.connect(lambda: self.on_destroy()) def on_network(self, event, *args): - if event == 'channel': + if event in ['channel','channels_updated']: # Handle in GUI thread (_network_signal -> on_network_qt) self._network_signal.emit(event, args) else: @@ -131,7 +131,7 @@ class QEChannelListModel(QAbstractListModel): def do_update(self, modelindex, channel): modelitem = self.channels[modelindex] - self._logger.debug(repr(modelitem)) + #self._logger.debug(repr(modelitem)) modelitem.update(self.channel_to_model(channel)) mi = self.createIndex(modelindex, 0) @@ -140,7 +140,7 @@ class QEChannelListModel(QAbstractListModel): @pyqtSlot(str) def new_channel(self, cid): - self._logger.debug('new channel with cid ' % cid) + self._logger.debug('new channel with cid %s' % cid) lnchannels = self.wallet.lnworker.channels for channel in lnchannels.values(): self._logger.debug(repr(channel)) @@ -153,7 +153,7 @@ class QEChannelListModel(QAbstractListModel): @pyqtSlot(str) def remove_channel(self, cid): - self._logger.debug('remove channel with cid ' % cid) + self._logger.debug('remove channel with cid %s' % cid) i = 0 for channel in self.channels: if cid == channel['cid']: From 0130e5aecf6f12e0e40ce18f4219abee6c0f249d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 5 Jul 2022 18:30:54 +0200 Subject: [PATCH 203/218] invert (in)validPassword property in QEWalletDB, add invalidPassword signal. This is to better support state in OpenWallet page --- electrum/gui/qml/components/OpenWallet.qml | 39 +++++++++++++++++++--- electrum/gui/qml/qedaemon.py | 4 +-- electrum/gui/qml/qewalletdb.py | 28 ++++++++-------- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml index 4131a34fc..715ae2685 100644 --- a/electrum/gui/qml/components/OpenWallet.qml +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -8,7 +8,7 @@ import "controls" Pane { id: openwalletdialog - + property string title: qsTr("Open Wallet") property string name @@ -39,7 +39,7 @@ Pane { Layout.columnSpan: 2 Layout.alignment: Qt.AlignHCenter text: qsTr("Invalid Password") - visible: wallet_db.invalidPassword && _unlockClicked + visible: !wallet_db.validPassword && _unlockClicked width: parent.width * 2/3 error: true } @@ -53,16 +53,24 @@ Pane { id: password visible: wallet_db.needsPassword echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + onTextChanged: { + unlockButton.enabled = true + _unlockClicked = false + } + onAccepted: { + unlock() + } } Button { + id: unlockButton Layout.columnSpan: 2 Layout.alignment: Qt.AlignHCenter visible: wallet_db.needsPassword text: qsTr("Unlock") onClicked: { - _unlockClicked = true - wallet_db.password = password.text + unlock() } } @@ -87,8 +95,22 @@ Pane { text: qsTr('Split wallet') onClicked: wallet_db.doSplit() } + + BusyIndicator { + id: busy + running: false + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + } } + function unlock() { + unlockButton.enabled = false + _unlockClicked = true + wallet_db.password = password.text + openwalletdialog.forceActiveFocus() + } + WalletDB { id: wallet_db path: openwalletdialog.path @@ -99,10 +121,17 @@ Pane { } onReadyChanged: { if (ready) { + busy.running = true Daemon.load_wallet(openwalletdialog.path, password.text) app.stack.pop(null) } } + onInvalidPassword: { + password.forceActiveFocus() + } + } + + Component.onCompleted: { + password.forceActiveFocus() } - } diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 958bd88ca..855488b72 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -97,7 +97,7 @@ class QEDaemon(AuthMixin, QObject): self.daemon = daemon self.qefx = QEFX(daemon.fx, daemon.config) self._walletdb = QEWalletDB() - self._walletdb.invalidPasswordChanged.connect(self.passwordValidityCheck) + self._walletdb.validPasswordChanged.connect(self.passwordValidityCheck) _logger = get_logger(__name__) _loaded_wallets = QEWalletListModel() @@ -115,7 +115,7 @@ class QEDaemon(AuthMixin, QObject): @pyqtSlot() def passwordValidityCheck(self): - if self._walletdb._invalidPassword: + if not self._walletdb._validPassword: self.walletRequiresPassword.emit() @pyqtSlot() diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index d3dae8e16..d97a57f50 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -25,20 +25,21 @@ class QEWalletDB(QObject): needsPasswordChanged = pyqtSignal() needsHWDeviceChanged = pyqtSignal() passwordChanged = pyqtSignal() - invalidPasswordChanged = pyqtSignal() + validPasswordChanged = pyqtSignal() requiresSplitChanged = pyqtSignal() splitFinished = pyqtSignal() readyChanged = pyqtSignal() createError = pyqtSignal([str], arguments=["error"]) createSuccess = pyqtSignal() - + invalidPassword = pyqtSignal() + def reset(self): self._path = None self._needsPassword = False self._needsHWDevice = False self._password = '' self._requiresSplit = False - self._invalidPassword = False + self._validPassword = True self._storage = None self._db = None @@ -110,15 +111,15 @@ class QEWalletDB(QObject): def requiresSplit(self): return self._requiresSplit - @pyqtProperty(bool, notify=invalidPasswordChanged) - def invalidPassword(self): - return self._invalidPassword + @pyqtProperty(bool, notify=validPasswordChanged) + def validPassword(self): + return self._validPassword - @invalidPassword.setter - def invalidPassword(self, invalidPassword): - if self._invalidPassword != invalidPassword: - self._invalidPassword = invalidPassword - self.invalidPasswordChanged.emit() + @validPassword.setter + def validPassword(self, validPassword): + if self._validPassword != validPassword: + self._validPassword = validPassword + self.validPasswordChanged.emit() @pyqtProperty(bool, notify=readyChanged) def ready(self): @@ -148,9 +149,10 @@ class QEWalletDB(QObject): try: self._storage.decrypt('' if not self._password else self._password) - self.invalidPassword = False + self.validPassword = True except InvalidPassword as e: - self.invalidPassword = True + self.validPassword = False + self.invalidPassword.emit() if not self._storage.is_past_initial_decryption(): self._storage = None From cd6d5e577b15233956c3ea82e0b8832e8de05db2 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Jul 2022 11:10:00 +0200 Subject: [PATCH 204/218] add unified wallet password support --- electrum/gui/qml/components/Wallets.qml | 15 ++++++++++++++- electrum/gui/qml/qedaemon.py | 25 +++++++++++++++++++++++-- electrum/gui/qml/qewallet.py | 7 +------ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index ec19f195f..5a3f816d1 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -42,7 +42,7 @@ Pane { function changePassword() { // trigger dialog via wallet (auth then signal) - Daemon.currentWallet.start_change_password() + Daemon.start_change_password() } property QtObject menu: Menu { @@ -307,6 +307,19 @@ Pane { Daemon.availableWallets.reload() app.stack.pop() } + function onRequestNewPassword() { // new unified password (all wallets) + var dialog = app.passwordDialog.createObject(app, + { + 'confirmPassword': true, + 'title': qsTr('Enter new password'), + 'infotext': qsTr('If you forget your password, you\'ll need to\ + restore from seed. Please make sure you have your seed stored safely') + } ) + dialog.accepted.connect(function() { + Daemon.set_password(dialog.password) + }) + dialog.open() + } } Connections { diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 855488b72..683cee94a 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -6,7 +6,7 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.util import register_callback, get_new_wallet_name, WalletFileException from electrum.logging import get_logger -from electrum.wallet import Wallet, Abstract_Wallet +from electrum.wallet import Wallet, Abstract_Wallet, update_password_for_directory from electrum.storage import WalletStorage, StorageReadWriteError from electrum.wallet_db import WalletDB @@ -104,7 +104,8 @@ class QEDaemon(AuthMixin, QObject): _available_wallets = None _current_wallet = None _path = None - + _use_single_password = False + _password = None walletLoaded = pyqtSignal() walletRequiresPassword = pyqtSignal() @@ -144,6 +145,12 @@ class QEDaemon(AuthMixin, QObject): self._loaded_wallets.add_wallet(wallet=wallet) self._current_wallet = QEWallet.getInstanceFor(wallet) self.walletLoaded.emit() + + if self.daemon.config.get('single_password'): + self._use_single_password = update_password_for_directory(self.daemon.config, password, password) + self._password = password + self._logger.info(f'use single password: {self._use_single_password}') + self.daemon.config.save_last_wallet(wallet) else: self._logger.info('could not open wallet') @@ -195,3 +202,17 @@ class QEDaemon(AuthMixin, QObject): i = i + 1 return f'wallet_{i}' + requestNewPassword = pyqtSignal() + @pyqtSlot() + @auth_protect + def start_change_password(self): + if self._use_single_password: + self.requestNewPassword.emit() + else: + self.currentWallet.requestNewPassword.emit() + + @pyqtSlot(str) + def set_password(self, password): + assert self._use_single_password + self._logger.debug('about to set password to %s for ALL wallets' % password) + update_password_for_directory(self.daemon.config, self._password, password) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index c44cb1e29..8e7e321ae 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -57,6 +57,7 @@ class QEWallet(AuthMixin, QObject): invoiceCreateError = pyqtSignal([str,str], arguments=['code','error']) paymentSucceeded = pyqtSignal([str], arguments=['key']) paymentFailed = pyqtSignal([str,str], arguments=['key','reason']) + requestNewPassword = pyqtSignal() _network_signal = pyqtSignal(str, object) @@ -499,12 +500,6 @@ class QEWallet(AuthMixin, QObject): except InvalidPassword as e: return False - requestNewPassword = pyqtSignal() - @pyqtSlot() - @auth_protect - def start_change_password(self): - self.requestNewPassword.emit() - @pyqtSlot(str) def set_password(self, password): storage = self.wallet.storage From cc778356eb32f11fcacd9a8b3224f43d7d1a602b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Jul 2022 16:28:03 +0200 Subject: [PATCH 205/218] allow paying when lightning invoice is in status FAILED --- electrum/gui/qml/qeinvoice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 08e90531d..82378b4e7 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -244,12 +244,12 @@ class QEInvoiceParser(QEInvoice): def determine_can_pay(self): if self.invoiceType == QEInvoice.Type.LightningInvoice: - if self.status == PR_UNPAID: + if self.status in [PR_UNPAID, PR_FAILED]: if self.get_max_spendable_lightning() >= self.amount.satsInt: self.canPay = True else: self.userinfo = _('Can\'t pay, insufficient balance') - else: + else: # TODO: proper text for other possible states self.userinfo = _('Can\'t pay, invoice is expired') elif self.invoiceType == QEInvoice.Type.OnchainInvoice: if self.get_max_spendable_onchain() >= self.amount.satsInt: From 2a13212dedf230996c331a94eb40c57a33382455 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Jul 2022 16:23:29 +0200 Subject: [PATCH 206/218] implement auth by PIN and allow auth override to wallet password by passing method='wallet' to auth_protect --- electrum/gui/qml/auth.py | 8 +- electrum/gui/qml/components/Pin.qml | 90 +++++++++++++++++++++ electrum/gui/qml/components/Preferences.qml | 50 ++++++++++++ electrum/gui/qml/components/main.qml | 79 ++++++++++++------ electrum/gui/qml/qeconfig.py | 19 ++++- 5 files changed, 215 insertions(+), 31 deletions(-) create mode 100644 electrum/gui/qml/components/Pin.qml diff --git a/electrum/gui/qml/auth.py b/electrum/gui/qml/auth.py index e55a0c036..a7e141204 100644 --- a/electrum/gui/qml/auth.py +++ b/electrum/gui/qml/auth.py @@ -4,9 +4,9 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot from electrum.logging import get_logger -def auth_protect(func=None, reject=None): +def auth_protect(func=None, reject=None, method='pin'): if func is None: - return partial(auth_protect, reject=reject) + return partial(auth_protect, reject=reject, method=method) @wraps(func) def wrapper(self, *args, **kwargs): @@ -15,14 +15,14 @@ def auth_protect(func=None, reject=None): self._logger.debug('object already has a pending authed function call') raise Exception('object already has a pending authed function call') setattr(self, '__auth_fcall', (func,args,kwargs,reject)) - getattr(self, 'authRequired').emit() + getattr(self, 'authRequired').emit(method) return wrapper class AuthMixin: _auth_logger = get_logger(__name__) - authRequired = pyqtSignal() + authRequired = pyqtSignal([str],arguments=['method']) @pyqtSlot() def authProceed(self): diff --git a/electrum/gui/qml/components/Pin.qml b/electrum/gui/qml/components/Pin.qml new file mode 100644 index 000000000..492135180 --- /dev/null +++ b/electrum/gui/qml/components/Pin.qml @@ -0,0 +1,90 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: root + + width: parent.width * 2/3 + height: parent.height * 1/3 + + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + focus: true + + standardButtons: Dialog.Cancel + + property string mode // [check, enter, change] + property string pincode // old one passed in when change, new one passed out + + property int _phase: mode == 'enter' ? 1 : 0 // 0 = existing pin, 1 = new pin, 2 = re-enter new pin + property string _pin + + function submit() { + if (_phase == 0) { + if (pin.text == pincode) { + pin.text = '' + if (mode == 'check') + accepted() + else + _phase = 1 + return + } + } + if (_phase == 1) { + _pin = pin.text + pin.text = '' + _phase = 2 + return + } + if (_phase == 2) { + if (_pin == pin.text) { + pincode = pin.text + accepted() + } + return + } + } + + ColumnLayout { + width: parent.width + height: parent.height + + Label { + text: [qsTr('Enter PIN'), qsTr('Enter New PIN'), qsTr('Re-enter New PIN')][_phase] + font.pixelSize: constants.fontSizeXXLarge + Layout.alignment: Qt.AlignHCenter + } + + TextField { + id: pin + Layout.preferredWidth: root.width *2/3 + Layout.alignment: Qt.AlignHCenter + font.pixelSize: constants.fontSizeXXLarge + maximumLength: 6 + inputMethodHints: Qt.ImhDigitsOnly + echoMode: TextInput.Password + focus: true + onTextChanged: { + if (text.length == 6) { + submit() + } + } + } + + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + } + +} diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 7509b73a2..ef3251ea8 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -6,6 +6,8 @@ import QtQuick.Controls.Material 2.0 import org.electrum 1.0 Pane { + id: preferences + property string title: qsTr("Preferences") ColumnLayout { @@ -116,6 +118,49 @@ Pane { } } + Label { + text: qsTr('PIN') + } + + RowLayout { + Label { + text: Config.pinCode == '' ? qsTr('Off'): qsTr('On') + color: Material.accentColor + Layout.rightMargin: constants.paddingMedium + } + Button { + text: qsTr('Enable') + visible: Config.pinCode == '' + onClicked: { + var dialog = pinSetup.createObject(preferences, {mode: 'enter'}) + dialog.accepted.connect(function() { + Config.pinCode = dialog.pincode + dialog.close() + }) + dialog.open() + } + } + Button { + text: qsTr('Change') + visible: Config.pinCode != '' + onClicked: { + var dialog = pinSetup.createObject(preferences, {mode: 'change', pincode: Config.pinCode}) + dialog.accepted.connect(function() { + Config.pinCode = dialog.pincode + dialog.close() + }) + dialog.open() + } + } + Button { + text: qsTr('Remove') + visible: Config.pinCode != '' + onClicked: { + Config.pinCode = '' + } + } + } + Label { text: qsTr('Lightning Routing') } @@ -136,6 +181,11 @@ Pane { } + Component { + id: pinSetup + Pin {} + } + Component.onCompleted: { baseUnit.currentIndex = ['BTC','mBTC','bits','sat'].indexOf(Config.baseUnit) thousands.checked = Config.thousandsSeparator diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index a2d048e3b..b496a4026 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -179,6 +179,14 @@ ApplicationWindow } } + property alias pinDialog: _pinDialog + Component { + id: _pinDialog + Pin { + onClosed: destroy() + } + } + NotificationPopup { id: notificationPopup } @@ -221,7 +229,7 @@ ApplicationWindow interval: 5000 repeat: false } - + Connections { target: Daemon function onWalletRequiresPassword() { @@ -233,6 +241,9 @@ ApplicationWindow var dialog = app.messageDialog.createObject(app, {'text': error}) dialog.open() } + function onAuthRequired(method) { + handleAuthRequired(Daemon, method) + } } Connections { @@ -244,45 +255,61 @@ ApplicationWindow Connections { target: Daemon.currentWallet - function onAuthRequired() { + function onAuthRequired(method) { + handleAuthRequired(Daemon.currentWallet, method) + } + // TODO: add to notification queue instead of barging through + function onPaymentSucceeded(key) { + notificationPopup.show(qsTr('Payment Succeeded')) + } + function onPaymentFailed(key, reason) { + notificationPopup.show(qsTr('Payment Failed') + ': ' + reason) + } + } + + Connections { + target: Config + function onAuthRequired(method) { + handleAuthRequired(Config, method) + } + } + + function handleAuthRequired(qtobject, method) { + console.log('AUTHENTICATING USING METHOD ' + method) + if (method == 'wallet') { if (Daemon.currentWallet.verify_password('')) { // wallet has no password - Daemon.currentWallet.authProceed() + qtobject.authProceed() } else { var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) dialog.accepted.connect(function() { if (Daemon.currentWallet.verify_password(dialog.password)) { - Daemon.currentWallet.authProceed() + qtobject.authProceed() } else { - Daemon.currentWallet.authCancel() + qtobject.authCancel() } }) dialog.rejected.connect(function() { - Daemon.currentWallet.authCancel() + qtobject.authCancel() + }) + dialog.open() + } + } else if (method == 'pin') { + if (Config.pinCode == '') { + // no PIN configured + qtobject.authProceed() + } else { + var dialog = app.pinDialog.createObject(app, {mode: 'check', pincode: Config.pinCode}) + dialog.accepted.connect(function() { + qtobject.authProceed() + dialog.close() + }) + dialog.rejected.connect(function() { + qtobject.authCancel() }) dialog.open() } - } - // TODO: add to notification queue instead of barging through - function onPaymentSucceeded(key) { - notificationPopup.show(qsTr('Payment Succeeded')) - } - function onPaymentFailed(key, reason) { - notificationPopup.show(qsTr('Payment Failed') + ': ' + reason) } } - Connections { - target: Daemon - function onAuthRequired() { - var dialog = app.messageDialog.createObject(app, {'text': 'Auth placeholder', 'yesno': true}) - dialog.yesClicked.connect(function() { - Daemon.authProceed() - }) - dialog.noClicked.connect(function() { - Daemon.authCancel() - }) - dialog.open() - } - } } diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 88bb0ee7e..cf1f28de6 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -6,8 +6,9 @@ from electrum.logging import get_logger from electrum.util import DECIMAL_POINT_DEFAULT, format_satoshis from .qetypes import QEAmount +from .auth import AuthMixin, auth_protect -class QEConfig(QObject): +class QEConfig(AuthMixin, QObject): def __init__(self, config, parent=None): super().__init__(parent) self.config = config @@ -80,6 +81,22 @@ class QEConfig(QObject): self.config.set_key('confirmed_only', not checked, True) self.spendUnconfirmedChanged.emit() + pinCodeChanged = pyqtSignal() + @pyqtProperty(str, notify=pinCodeChanged) + def pinCode(self): + return self.config.get('pin_code', '') + + @pinCode.setter + def pinCode(self, pin_code): + if pin_code == '': + self.pinCodeRemoveAuth() + self.config.set_key('pin_code', pin_code, True) + self.pinCodeChanged.emit() + + @auth_protect(method='wallet') + def pinCodeRemoveAuth(self): + pass # no-op + useGossipChanged = pyqtSignal() @pyqtProperty(bool, notify=useGossipChanged) def useGossip(self): From 537dbab52238a151d02ad41de48c70defa0f7f6b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 6 Jul 2022 19:22:19 +0200 Subject: [PATCH 207/218] fix canRbf value in Send.qml --- electrum/gui/qml/components/Send.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml index 96d0aa313..972b76a38 100644 --- a/electrum/gui/qml/components/Send.qml +++ b/electrum/gui/qml/components/Send.qml @@ -245,7 +245,7 @@ Pane { title: qsTr('Confirm Payment') finalizer: TxFinalizer { wallet: Daemon.currentWallet - canRbf: True + canRbf: true } } } From 1f827f71d2897bf6252bdb60449438f7fed7ac26 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Jul 2022 15:22:15 +0200 Subject: [PATCH 208/218] add info text for all remaining invoice states --- electrum/gui/qml/qeinvoice.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 82378b4e7..6de0b5715 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -249,13 +249,27 @@ class QEInvoiceParser(QEInvoice): self.canPay = True else: self.userinfo = _('Can\'t pay, insufficient balance') - else: # TODO: proper text for other possible states - self.userinfo = _('Can\'t pay, invoice is expired') + else: + self.userinfo = { + PR_EXPIRED: _('Can\'t pay, invoice is expired'), + PR_PAID: _('Can\'t pay, invoice is already paid'), + PR_INFLIGHT: _('Can\'t pay, invoice is already being paid'), + PR_ROUTING: _('Can\'t pay, invoice is already being paid'), + PR_UNKNOWN: _('Can\'t pay, invoice has unknown status'), + }[self.status] elif self.invoiceType == QEInvoice.Type.OnchainInvoice: - if self.get_max_spendable_onchain() >= self.amount.satsInt: - self.canPay = True + if self.status in [PR_UNPAID, PR_FAILED]: + if self.get_max_spendable_onchain() >= self.amount.satsInt: + self.canPay = True + else: + self.userinfo = _('Can\'t pay, insufficient balance') else: - self.userinfo = _('Can\'t pay, insufficient balance') + self.userinfo = { + PR_EXPIRED: _('Can\'t pay, invoice is expired'), + PR_PAID: _('Can\'t pay, invoice is already paid'), + PR_UNCONFIRMED: _('Can\'t pay, invoice is already paid'), + PR_UNKNOWN: _('Can\'t pay, invoice has unknown status'), + }[self.status] def get_max_spendable_lightning(self): From 0228169852b50728e6bd8815bc0e0f6d68a6b9e5 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 7 Jul 2022 19:10:20 +0200 Subject: [PATCH 209/218] refactor to new event listener framework --- electrum/gui/qml/qechanneldetails.py | 17 ++- electrum/gui/qml/qechannellistmodel.py | 41 +++---- electrum/gui/qml/qedaemon.py | 4 +- electrum/gui/qml/qefx.py | 18 ++-- electrum/gui/qml/qeinvoice.py | 4 +- electrum/gui/qml/qenetwork.py | 30 +++--- electrum/gui/qml/qewallet.py | 143 +++++++++++++++---------- electrum/gui/qml/util.py | 31 ++++++ 8 files changed, 175 insertions(+), 113 deletions(-) create mode 100644 electrum/gui/qml/util.py diff --git a/electrum/gui/qml/qechanneldetails.py b/electrum/gui/qml/qechanneldetails.py index 527083fb4..020a17568 100644 --- a/electrum/gui/qml/qechanneldetails.py +++ b/electrum/gui/qml/qechanneldetails.py @@ -5,14 +5,14 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS from electrum.i18n import _ from electrum.gui import messages from electrum.logging import get_logger -from electrum.util import register_callback, unregister_callback from electrum.lnutil import LOCAL, REMOTE from electrum.lnchannel import ChanCloseOption from .qewallet import QEWallet from .qetypes import QEAmount +from .util import QtEventListener, qt_event_listener -class QEChannelDetails(QObject): +class QEChannelDetails(QObject, QtEventListener): _logger = get_logger(__name__) _wallet = None @@ -25,17 +25,16 @@ class QEChannelDetails(QObject): def __init__(self, parent=None): super().__init__(parent) - register_callback(self.on_network, ['channel']) + self.register_callbacks() self.destroyed.connect(lambda: self.on_destroy()) - def on_network(self, event, *args): - if event == 'channel': - wallet, channel = args - if wallet == self._wallet.wallet and self._channelid == channel.channel_id.hex(): - self.channelChanged.emit() + @qt_event_listener + def on_event_channel(self, wallet, channel): + if wallet == self._wallet.wallet and self._channelid == channel.channel_id.hex(): + self.channelChanged.emit() def on_destroy(self): - unregister_callback(self.on_network) + self.unregister_callbacks() walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py index 09a4f15f0..94bfcbb46 100644 --- a/electrum/gui/qml/qechannellistmodel.py +++ b/electrum/gui/qml/qechannellistmodel.py @@ -4,13 +4,14 @@ 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, register_callback, unregister_callback +from electrum.util import Satoshis from electrum.lnutil import LOCAL, REMOTE from electrum.lnchannel import ChannelState from .qetypes import QEAmount +from .util import QtEventListener, qt_event_listener -class QEChannelListModel(QAbstractListModel): +class QEChannelListModel(QAbstractListModel, QtEventListener): _logger = get_logger(__name__) # define listmodel rolemap @@ -28,38 +29,26 @@ class QEChannelListModel(QAbstractListModel): self.wallet = wallet self.init_model() - self._network_signal.connect(self.on_network_qt) - interests = ['channel', 'channels_updated', 'gossip_peers', - 'ln_gossip_sync_progress', 'unknown_channels', - 'channel_db', 'gossip_db_loaded'] # To avoid leaking references to "self" that prevent the # window from being GC-ed when closed, callbacks should be # methods of this class only, and specifically not be # partials, lambdas or methods of subobjects. Hence... - register_callback(self.on_network, interests) + self.register_callbacks() self.destroyed.connect(lambda: self.on_destroy()) - def on_network(self, event, *args): - if event in ['channel','channels_updated']: - # Handle in GUI thread (_network_signal -> on_network_qt) - self._network_signal.emit(event, args) - else: - self.on_network_qt(event, args) - - def on_network_qt(self, event, args=None): - if event == 'channel': - wallet, channel = args - if wallet == self.wallet: - self.on_channel_updated(channel) - elif event == 'channels_updated': - wallet, = args - if wallet == self.wallet: - self.init_model() # TODO: remove/add less crude than full re-init - else: - self._logger.debug('unhandled event %s: %s' % (event, repr(args))) + @qt_event_listener + def on_event_channel(self, wallet, channel): + if wallet == self.wallet: + self.on_channel_updated(channel) + + # elif event == 'channels_updated': + @qt_event_listener + def on_event_channels_updated(self, wallet): + if wallet == self.wallet: + self.init_model() # TODO: remove/add less crude than full re-init def on_destroy(self): - unregister_callback(self.on_network) + self.unregister_callbacks() def rowCount(self, index): return len(self.channels) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 683cee94a..83c952710 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -6,7 +6,7 @@ from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.util import register_callback, get_new_wallet_name, WalletFileException from electrum.logging import get_logger -from electrum.wallet import Wallet, Abstract_Wallet, update_password_for_directory +from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import WalletStorage, StorageReadWriteError from electrum.wallet_db import WalletDB @@ -147,7 +147,7 @@ class QEDaemon(AuthMixin, QObject): self.walletLoaded.emit() if self.daemon.config.get('single_password'): - self._use_single_password = update_password_for_directory(self.daemon.config, password, password) + self._use_single_password = self.daemon.update_password_for_directory(old_password=password, new_password=password) self._password = password self._logger.info(f'use single password: {self._use_single_password}') diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index 2de44e567..580910025 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -6,28 +6,34 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger from electrum.exchange_rate import FxThread from electrum.simple_config import SimpleConfig -from electrum.util import register_callback from electrum.bitcoin import COIN from .qetypes import QEAmount +from .util import QtEventListener, qt_event_listener -class QEFX(QObject): +class QEFX(QObject, QtEventListener): def __init__(self, fxthread: FxThread, config: SimpleConfig, parent=None): super().__init__(parent) self.fx = fxthread self.config = config - register_callback(self.on_quotes, ['on_quotes']) - register_callback(self.on_history, ['on_history']) + self.register_callbacks() + self.destroyed.connect(lambda: self.on_destroy()) _logger = get_logger(__name__) quotesUpdated = pyqtSignal() - def on_quotes(self, event, *args): + + def on_destroy(self): + self.unregister_callbacks() + + @qt_event_listener + def on_event_on_quotes(self, *args): self._logger.debug('new quotes') self.quotesUpdated.emit() historyUpdated = pyqtSignal() - def on_history(self, event, *args): + @qt_event_listener + def on_event_on_history(self, *args): self._logger.debug('new history') self.historyUpdated.emit() diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 6de0b5715..63dbdec51 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -6,7 +6,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS from electrum.logging import get_logger from electrum.i18n import _ from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, - maybe_extract_bolt11_invoice) + maybe_extract_lightning_payment_identifier) from electrum.invoices import Invoice from electrum.invoices import (PR_UNPAID,PR_EXPIRED,PR_UNKNOWN,PR_PAID,PR_INFLIGHT, PR_FAILED,PR_ROUTING,PR_UNCONFIRMED,LN_EXPIRY_NEVER) @@ -335,7 +335,7 @@ class QEInvoiceParser(QEInvoice): lninvoice = None try: - maybe_lightning_invoice = maybe_extract_bolt11_invoice(maybe_lightning_invoice) + maybe_lightning_invoice = maybe_extract_lightning_payment_identifier(maybe_lightning_invoice) lninvoice = Invoice.from_bech32(maybe_lightning_invoice) except InvoiceError as e: pass diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py index 73bad2856..190efc6a5 100644 --- a/electrum/gui/qml/qenetwork.py +++ b/electrum/gui/qml/qenetwork.py @@ -1,20 +1,16 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject -from electrum.util import register_callback from electrum.logging import get_logger from electrum import constants from electrum.interface import ServerAddr -class QENetwork(QObject): +from .util import QtEventListener, qt_event_listener + +class QENetwork(QObject, QtEventListener): def __init__(self, network, parent=None): super().__init__(parent) self.network = network - register_callback(self.on_network_updated, ['network_updated']) - register_callback(self.on_blockchain_updated, ['blockchain_updated']) - register_callback(self.on_default_server_changed, ['default_server_changed']) - register_callback(self.on_proxy_set, ['proxy_set']) - register_callback(self.on_status, ['status']) - register_callback(self.on_fee_histogram, ['fee_histogram']) + self.register_callbacks() _logger = get_logger(__name__) @@ -33,30 +29,36 @@ class QENetwork(QObject): _height = 0 _status = "" - def on_network_updated(self, event, *args): + @qt_event_listener + def on_event_network_updated(self, *args): self.networkUpdated.emit() - def on_blockchain_updated(self, event, *args): + @qt_event_listener + def on_event_blockchain_updated(self, *args): if self._height != self.network.get_local_height(): self._height = self.network.get_local_height() self._logger.debug('new height: %d' % self._height) self.heightChanged.emit(self._height) self.blockchainUpdated.emit() - def on_default_server_changed(self, event, *args): + @qt_event_listener + def on_event_default_server_changed(self, *args): self.defaultServerChanged.emit() - def on_proxy_set(self, event, *args): + @qt_event_listener + def on_event_proxy_set(self, *args): self._logger.debug('proxy set') self.proxySet.emit() - def on_status(self, event, *args): + @qt_event_listener + def on_event_status(self, *args): self._logger.debug('status updated: %s' % self.network.connection_status) if self._status != self.network.connection_status: self._status = self.network.connection_status self.statusChanged.emit() - def on_fee_histogram(self, event, *args): + @qt_event_listener + def on_event_fee_histogram(self, *args): self._logger.debug('fee histogram updated') self.feeHistogramUpdated.emit() diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 8e7e321ae..cafa5ce86 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -7,8 +7,8 @@ import threading from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer from electrum.i18n import _ -from electrum.util import (register_callback, unregister_callback, - Satoshis, format_time, parse_max_spend, InvalidPassword) +from electrum.util import (Satoshis, format_time, parse_max_spend, InvalidPassword, + event_listener) from electrum.logging import get_logger from electrum.wallet import Wallet, Abstract_Wallet from electrum.storage import StorageEncryptionVersion @@ -24,8 +24,9 @@ from .qeaddresslistmodel import QEAddressListModel from .qechannellistmodel import QEChannelListModel from .qetypes import QEAmount from .auth import AuthMixin, auth_protect +from .util import QtEventListener, qt_event_listener -class QEWallet(AuthMixin, QObject): +class QEWallet(AuthMixin, QObject, QtEventListener): __instances = [] # this factory method should be used to instantiate QEWallet @@ -79,7 +80,7 @@ class QEWallet(AuthMixin, QObject): self.notification_timer.setInterval(500) # msec self.notification_timer.timeout.connect(self.notify_transactions) - self._network_signal.connect(self.on_network_qt) + #self._network_signal.connect(self.on_network_qt) interests = ['wallet_updated', 'new_transaction', 'status', 'verified', 'on_history', 'channel', 'channels_updated', 'payment_failed', 'payment_succeeded', 'invoice_status', 'request_status'] @@ -87,7 +88,8 @@ class QEWallet(AuthMixin, QObject): # window from being GC-ed when closed, callbacks should be # methods of this class only, and specifically not be # partials, lambdas or methods of subobjects. Hence... - register_callback(self.on_network, interests) + #register_callback(self.on_network, interests) + self.register_callbacks() self.destroyed.connect(lambda: self.on_destroy()) @pyqtProperty(bool, notify=isUptodateChanged) @@ -108,60 +110,93 @@ class QEWallet(AuthMixin, QObject): wallet = args[0] if wallet == self.wallet: self._logger.debug('event %s' % event) - if event == 'status': + + @event_listener + def on_event_status(self, *args, **kwargs): + #if event == 'status': self.isUptodateChanged.emit() - elif event == 'request_status': - wallet, key, status = args - if wallet == self.wallet: - self._logger.debug('request status %d for key %s' % (status, key)) - self.requestStatusChanged.emit(key, status) - elif event == 'invoice_status': - wallet, key = args - if wallet == self.wallet: - self._logger.debug('invoice status update for key %s' % key) - # FIXME event doesn't pass the new status, so we need to retrieve - invoice = self.wallet.get_invoice(key) - if invoice: - status = self.wallet.get_invoice_status(invoice) - self.invoiceStatusChanged.emit(key, status) - else: - self._logger.debug(f'No invoice found for key {key}') - elif event == 'new_transaction': - wallet, tx = args - if wallet == self.wallet: - self.add_tx_notification(tx) - self.historyModel.init_model() # TODO: be less dramatic - elif event == 'verified': - wallet, txid, info = args - if wallet == self.wallet: - self.historyModel.update_tx(txid, info) - elif event == 'wallet_updated': - wallet, = args - if wallet == self.wallet: - self._logger.debug('wallet %s updated' % str(wallet)) - self.balanceChanged.emit() - elif event == 'channel': - wallet, channel = args - if wallet == self.wallet: - self.balanceChanged.emit() - elif event == 'channels_updated': - wallet, = args + + + # elif event == 'request_status': + @event_listener + def on_event_request_status(self, wallet, key, status): + #wallet, key, status = args + if wallet == self.wallet: + self._logger.debug('request status %d for key %s' % (status, key)) + self.requestStatusChanged.emit(key, status) + # elif event == 'invoice_status': + @event_listener + def on_event_invoice_status(self, wallet, key): + #wallet, key = args + if wallet == self.wallet: + self._logger.debug('invoice status update for key %s' % key) + # FIXME event doesn't pass the new status, so we need to retrieve + invoice = self.wallet.get_invoice(key) + if invoice: + status = self.wallet.get_invoice_status(invoice) + self.invoiceStatusChanged.emit(key, status) + else: + self._logger.debug(f'No invoice found for key {key}') + + #elif event == 'new_transaction': + @qt_event_listener + def on_event_new_transaction(self, *args): + wallet, tx = args + if wallet == self.wallet: + self.add_tx_notification(tx) + self.historyModel.init_model() # TODO: be less dramatic + + + # elif event == 'verified': + @qt_event_listener + def on_event_verified(self, wallet, txid, info): + #wallet, txid, info = args + if wallet == self.wallet: + self.historyModel.update_tx(txid, info) + + + # elif event == 'wallet_updated': + @event_listener + def on_event_wallet_updated(self, wallet): + #wallet, = args + if wallet == self.wallet: + self._logger.debug('wallet %s updated' % str(wallet)) + self.balanceChanged.emit() + + # elif event == 'channel': + @event_listener + def on_event_channel(self, wallet, channel): + #wallet, channel = args if wallet == self.wallet: self.balanceChanged.emit() - elif event == 'payment_succeeded': - wallet, key = args - if wallet == self.wallet: - self.paymentSucceeded.emit(key) - self.historyModel.init_model() # TODO: be less dramatic - elif event == 'payment_failed': - wallet, key, reason = args - if wallet == self.wallet: - self.paymentFailed.emit(key, reason) - else: - self._logger.debug('unhandled event: %s %s' % (event, str(args))) + + # elif event == 'channels_updated': + @event_listener + def on_event_channels_updated(self, wallet): + #wallet, = args + if wallet == self.wallet: + self.balanceChanged.emit() + # elif event == 'payment_succeeded': + + @qt_event_listener + def on_event_payment_succeeded(self, wallet, key): + #wallet, key = args + if wallet == self.wallet: + self.paymentSucceeded.emit(key) + self.historyModel.init_model() # TODO: be less dramatic + + # elif event == 'payment_failed': + @event_listener + def on_event_payment_failed(self, wallet, key, reason): + #wallet, key, reason = args + if wallet == self.wallet: + self.paymentFailed.emit(key, reason) + #else: + #self._logger.debug('unhandled event: %s %s' % (event, str(args))) def on_destroy(self): - unregister_callback(self.on_network) + #unregister_callback(self.on_network) + self.unregister_callbacks() def add_tx_notification(self, tx): self._logger.debug('new transaction event') diff --git a/electrum/gui/qml/util.py b/electrum/gui/qml/util.py new file mode 100644 index 000000000..79aff327a --- /dev/null +++ b/electrum/gui/qml/util.py @@ -0,0 +1,31 @@ +from functools import wraps + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.util import EventListener, event_listener + +class QtEventListener(EventListener): + + qt_callback_signal = pyqtSignal(tuple) + + def register_callbacks(self): + self.qt_callback_signal.connect(self.on_qt_callback_signal) + EventListener.register_callbacks(self) + + def unregister_callbacks(self): + #self.qt_callback_signal.disconnect() + EventListener.unregister_callbacks(self) + + def on_qt_callback_signal(self, args): + func = args[0] + return func(self, *args[1:]) + +# decorator for members of the QtEventListener class +def qt_event_listener(func): + func = event_listener(func) + @wraps(func) + def decorator(self, *args): + self.qt_callback_signal.emit( (func,) + args) + return decorator From f5933da348f0c20aafaa6ba9020fc151dd8a5c21 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 8 Jul 2022 11:10:15 +0200 Subject: [PATCH 210/218] skip wallet files with leading dot --- electrum/gui/qml/qedaemon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 83c952710..c5d5cddc6 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -79,7 +79,7 @@ class QEAvailableWalletListModel(QEWalletListModel): wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path()) with os.scandir(wallet_folder) as it: for i in it: - if i.is_file(): + if i.is_file() and not i.name.startswith('.'): available.append(i.path) for path in sorted(available): wallet = self.daemon.get_wallet(path) From a5fc06748163e1dc562759f3b93fab6f32133401 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 8 Jul 2022 12:30:27 +0200 Subject: [PATCH 211/218] take out routing hints retrieval for display for now. --- electrum/gui/qml/qeinvoice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 63dbdec51..a0c57bf3f 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -202,8 +202,8 @@ class QEInvoiceParser(QEInvoice): self._logger.debug(str(lnaddr.get_routing_info('t'))) return { 'pubkey': lnaddr.pubkey.serialize().hex(), - 't': lnaddr.get_routing_info('t')[0][0].hex(), - 'r': lnaddr.get_routing_info('r')[0][0][0].hex() + 't': '', #lnaddr.get_routing_info('t')[0][0].hex(), + 'r': '' #lnaddr.get_routing_info('r')[0][0][0].hex() } @pyqtSlot() From b8b629ca6668203beb37d837139bf9ab6aa255f5 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 8 Jul 2022 13:31:40 +0200 Subject: [PATCH 212/218] run_electrum: (android) restore behaviour of only logging in DEBUG builds --- run_electrum | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run_electrum b/run_electrum index 23822cf09..4b3565fda 100755 --- a/run_electrum +++ b/run_electrum @@ -327,7 +327,7 @@ def main(): from jnius import autoclass build_config = autoclass("org.electrum.electrum.BuildConfig") config_options = { - 'verbosity': '*', #if build_config.DEBUG else '', + 'verbosity': '*' if build_config.DEBUG else '', 'cmd': 'gui', 'gui': android_gui, 'single_password':True, From 75e7f5e2f851bc9dc94e48e720e320f31dd9905f Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 8 Jul 2022 13:32:41 +0200 Subject: [PATCH 213/218] run_electrum: fix DeprecationWarning re importlib.find_loader --- run_electrum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_electrum b/run_electrum index 4b3565fda..163eb6659 100755 --- a/run_electrum +++ b/run_electrum @@ -322,8 +322,8 @@ def main(): # config is an object passed to the various constructors (wallet, interface, gui) if is_android: - import importlib - android_gui = 'kivy' if importlib.find_loader('kivy') else 'qml' + import importlib.util + android_gui = 'kivy' if importlib.util.find_spec('kivy') else 'qml' from jnius import autoclass build_config = autoclass("org.electrum.electrum.BuildConfig") config_options = { From 3d0baf8d00e214dd16368aae7c974a43c789a9d9 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 8 Jul 2022 13:35:28 +0200 Subject: [PATCH 214/218] android build: restore prev "make theming" behaviour - note: "make theming" is kivy-specific, and not needed for the qml gui apk - note: "make theming" does not run automatically as part of the build scripts, although it used to in the past. For reproducible builds, the "electrum/gui/kivy/theming/atlas" git submodule contains the build artefacts. However, the user is supposed to manually run "make theming" when changing the atlas/images. --- contrib/android/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/android/Makefile b/contrib/android/Makefile index 1dcf7ecf4..93d426877 100644 --- a/contrib/android/Makefile +++ b/contrib/android/Makefile @@ -25,7 +25,7 @@ export BUILD_TIME := $(shell LC_ALL=C TZ=UTC date +'%H:%M:%S' -d @$(SOUR theming: #bash -c 'for i in network lightning; do convert -background none theming/light/$i.{svg,png}; done' - #$(PYTHON) -m kivy.atlas ../../electrum/gui/kivy/theming/atlas/light 1024 ../../electrum/gui/kivy/theming/light/*.png + $(PYTHON) -m kivy.atlas ../../electrum/gui/kivy/theming/atlas/light 1024 ../../electrum/gui/kivy/theming/light/*.png prepare: # running pre build setup # copy electrum to main.py From 2c656a0cf793c024f4ecee2442c6fa78f327e15f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 8 Jul 2022 16:23:57 +0200 Subject: [PATCH 215/218] add excepthooks, hoping to force a backtrace log when qt5 SIGABRTs --- electrum/gui/qml/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 818ed39c6..f0113352b 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -36,6 +36,10 @@ if TYPE_CHECKING: from .qeapp import ElectrumQmlApplication +class UncaughtException(Exception): + pass + + class ElectrumGui(Logger): @profiler @@ -71,6 +75,9 @@ class ElectrumGui(Logger): self.timer.setInterval(500) # msec self.timer.timeout.connect(lambda: None) # periodically enter python scope + sys.excepthook = self.excepthook + threading.excepthook = self.texcepthook + # Initialize any QML plugins run_hook('init_qml', self) self.app.engine.load('electrum/gui/qml/components/main.qml') @@ -78,6 +85,18 @@ class ElectrumGui(Logger): def close(self): self.app.quit() + def excepthook(self, exc_type, exc_value, exc_tb): + tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) + self.logger.exception(tb) + self.app._valid = False + self.close() + + def texcepthook(self, arg): + tb = "".join(traceback.format_exception(arg.exc_type, arg.exc_value, arg.exc_tb)) + self.logger.exception(tb) + self.app._valid = False + self.close() + def main(self): if not self.app._valid: return From 2c92174ee0a4f32aa55a21b8aa4bdd4a419c5161 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 8 Jul 2022 16:27:18 +0200 Subject: [PATCH 216/218] qewallet: fix useNotify signal emit --- electrum/gui/qml/qewallet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index cafa5ce86..39c0ae2e7 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -443,7 +443,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener): fut = asyncio.run_coroutine_threadsafe(coro, self.wallet.network.asyncio_loop) fut.result() except Exception as e: - self.userNotify(repr(e)) + self.userNotify.emit(repr(e)) threading.Thread(target=pay_thread).start() From bc88e1c32856fd1dfc97f950c254d0c60fd71303 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 8 Jul 2022 16:32:18 +0200 Subject: [PATCH 217/218] android build: (qml) bump p4a commit to include single new commit https://github.com/SomberNight/python-for-android/commit/c6e39ae1fb4eb8d547eb70b26b89beda7e6ff4b6 --- contrib/android/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/android/Dockerfile b/contrib/android/Dockerfile index 664f2ba27..338e961d4 100644 --- a/contrib/android/Dockerfile +++ b/contrib/android/Dockerfile @@ -169,8 +169,8 @@ RUN cd /opt \ && git remote add sombernight https://github.com/SomberNight/python-for-android \ && git remote add accumulator https://github.com/accumulator/python-for-android \ && git fetch --all \ - # commit: from branch accumulator/qt5-wip - && git checkout "fff4014747ab675bde3659f277095ac52ddf01f5^{commit}" \ + # commit: from branch sombernight/qt5-wip + && git checkout "c6e39ae1fb4eb8d547eb70b26b89beda7e6ff4b6^{commit}" \ && python3 -m pip install --no-build-isolation --no-dependencies --user -e . # build env vars From d79da7a24894500c9fb6c3f98a8241ac40aa447f Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 8 Jul 2022 16:34:58 +0200 Subject: [PATCH 218/218] android build: (qml) pin new transitive dependencies for reproducibility --- contrib/android/p4a_recipes/Pillow/__init__.py | 18 ++++++++++++++++++ .../android/p4a_recipes/freetype/__init__.py | 18 ++++++++++++++++++ contrib/android/p4a_recipes/jpeg/__init__.py | 18 ++++++++++++++++++ .../android/p4a_recipes/libiconv/__init__.py | 18 ++++++++++++++++++ .../android/p4a_recipes/libzbar/__init__.py | 18 ++++++++++++++++++ contrib/android/p4a_recipes/png/__init__.py | 18 ++++++++++++++++++ contrib/android/p4a_recipes/pyqt5/__init__.py | 18 ++++++++++++++++++ .../android/p4a_recipes/pyqt5sip/__init__.py | 18 ++++++++++++++++++ contrib/android/p4a_recipes/qt5/__init__.py | 8 ++++++++ 9 files changed, 152 insertions(+) create mode 100644 contrib/android/p4a_recipes/Pillow/__init__.py create mode 100644 contrib/android/p4a_recipes/freetype/__init__.py create mode 100644 contrib/android/p4a_recipes/jpeg/__init__.py create mode 100644 contrib/android/p4a_recipes/libiconv/__init__.py create mode 100644 contrib/android/p4a_recipes/libzbar/__init__.py create mode 100644 contrib/android/p4a_recipes/png/__init__.py create mode 100644 contrib/android/p4a_recipes/pyqt5/__init__.py create mode 100644 contrib/android/p4a_recipes/pyqt5sip/__init__.py create mode 100644 contrib/android/p4a_recipes/qt5/__init__.py diff --git a/contrib/android/p4a_recipes/Pillow/__init__.py b/contrib/android/p4a_recipes/Pillow/__init__.py new file mode 100644 index 000000000..a08cd3b28 --- /dev/null +++ b/contrib/android/p4a_recipes/Pillow/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.Pillow import PillowRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PillowRecipe._version == "7.0.0" +assert PillowRecipe.depends == ['png', 'jpeg', 'freetype', 'setuptools', 'python3'] +assert PillowRecipe.python_depends == [] + + +class PillowRecipePinned(util.InheritedRecipeMixin, PillowRecipe): + sha512sum = "187173a525d4f3f01b4898633263b53a311f337aa7b159c64f79ba8c7006fd44798a058e7cc5d8f1116bad008e4142ff303456692329fe73b0e115ef5c225d73" + + +recipe = PillowRecipePinned() diff --git a/contrib/android/p4a_recipes/freetype/__init__.py b/contrib/android/p4a_recipes/freetype/__init__.py new file mode 100644 index 000000000..0d8920655 --- /dev/null +++ b/contrib/android/p4a_recipes/freetype/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.freetype import FreetypeRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert FreetypeRecipe._version == "2.10.1" +assert FreetypeRecipe.depends == [] +assert FreetypeRecipe.python_depends == [] + + +class FreetypeRecipePinned(util.InheritedRecipeMixin, FreetypeRecipe): + sha512sum = "346c682744bcf06ca9d71265c108a242ad7d78443eff20142454b72eef47ba6d76671a6e931ed4c4c9091dd8f8515ebdd71202d94b073d77931345ff93cfeaa7" + + +recipe = FreetypeRecipePinned() diff --git a/contrib/android/p4a_recipes/jpeg/__init__.py b/contrib/android/p4a_recipes/jpeg/__init__.py new file mode 100644 index 000000000..dc3e2998e --- /dev/null +++ b/contrib/android/p4a_recipes/jpeg/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.jpeg import JpegRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert JpegRecipe._version == "2.0.1" +assert JpegRecipe.depends == [] +assert JpegRecipe.python_depends == [] + + +class JpegRecipePinned(util.InheritedRecipeMixin, JpegRecipe): + sha512sum = "d456515dcda7c5e2e257c9fd1441f3a5cff0d33281237fb9e3584bbec08a181c4b037947a6f87d805977ec7528df39b12a5d32f6e8db878a62bcc90482f86e0e" + + +recipe = JpegRecipePinned() diff --git a/contrib/android/p4a_recipes/libiconv/__init__.py b/contrib/android/p4a_recipes/libiconv/__init__.py new file mode 100644 index 000000000..4d0ba2f59 --- /dev/null +++ b/contrib/android/p4a_recipes/libiconv/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.libiconv import LibIconvRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert LibIconvRecipe._version == "1.15" +assert LibIconvRecipe.depends == [] +assert LibIconvRecipe.python_depends == [] + + +class LibIconvRecipePinned(util.InheritedRecipeMixin, LibIconvRecipe): + sha512sum = "1233fe3ca09341b53354fd4bfe342a7589181145a1232c9919583a8c9979636855839049f3406f253a9d9829908816bb71fd6d34dd544ba290d6f04251376b1a" + + +recipe = LibIconvRecipePinned() diff --git a/contrib/android/p4a_recipes/libzbar/__init__.py b/contrib/android/p4a_recipes/libzbar/__init__.py new file mode 100644 index 000000000..531ed598f --- /dev/null +++ b/contrib/android/p4a_recipes/libzbar/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.libzbar import LibZBarRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert LibZBarRecipe._version == "0.10" +assert LibZBarRecipe.depends == ['libiconv'] +assert LibZBarRecipe.python_depends == [] + + +class LibZBarRecipePinned(util.InheritedRecipeMixin, LibZBarRecipe): + sha512sum = "d624f8ab114bf59c62e364f8b3e334bece48f5c11654739d810ed2b8553b8390a70763b0ae12d83c1472cfeda5d9e1a0b7c9c60228a79bf9f5a6fae4a9f7ccb9" + + +recipe = LibZBarRecipePinned() diff --git a/contrib/android/p4a_recipes/png/__init__.py b/contrib/android/p4a_recipes/png/__init__.py new file mode 100644 index 000000000..b4f500a2e --- /dev/null +++ b/contrib/android/p4a_recipes/png/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.png import PngRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PngRecipe._version == "1.6.37" +assert PngRecipe.depends == [] +assert PngRecipe.python_depends == [] + + +class PngRecipePinned(util.InheritedRecipeMixin, PngRecipe): + sha512sum = "f304f8aaaee929dbeff4ee5260c1ab46d231dcb0261f40f5824b5922804b6b4ed64c91cbf6cc1e08554c26f50ac017899a5971190ca557bc3c11c123379a706f" + + +recipe = PngRecipePinned() diff --git a/contrib/android/p4a_recipes/pyqt5/__init__.py b/contrib/android/p4a_recipes/pyqt5/__init__.py new file mode 100644 index 000000000..16e74e5d8 --- /dev/null +++ b/contrib/android/p4a_recipes/pyqt5/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.pyqt5 import PyQt5Recipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PyQt5Recipe._version == "5.15.6" +assert PyQt5Recipe.depends == ['qt5', 'pyjnius', 'setuptools', 'pyqt5sip'] +assert PyQt5Recipe.python_depends == [] + + +class PyQt5RecipePinned(util.InheritedRecipeMixin, PyQt5Recipe): + sha512sum = "65fd663cb70e8701e49bd4b39dc9384546cf2edd1b3bab259ca64b50908f48bdc02ca143f36cd6b429075f5616dcc7b291607dcb63afa176e828cded3b82f5c7" + + +recipe = PyQt5RecipePinned() diff --git a/contrib/android/p4a_recipes/pyqt5sip/__init__.py b/contrib/android/p4a_recipes/pyqt5sip/__init__.py new file mode 100644 index 000000000..fd760faac --- /dev/null +++ b/contrib/android/p4a_recipes/pyqt5sip/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.pyqt5sip import PyQt5SipRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PyQt5SipRecipe._version == "12.9.0" +assert PyQt5SipRecipe.depends == ['setuptools', 'python3'] +assert PyQt5SipRecipe.python_depends == [] + + +class PyQt5SipRecipePinned(util.InheritedRecipeMixin, PyQt5SipRecipe): + sha512sum = "ca6f3b18b64391fded88732a8109a04d85727bbddecdf126679b187c7f0487c3c1f69ada3e8c54051281a43c6f2de70390ac5ff18a1bed79994070ddde730c5f" + + +recipe = PyQt5SipRecipePinned() diff --git a/contrib/android/p4a_recipes/qt5/__init__.py b/contrib/android/p4a_recipes/qt5/__init__.py new file mode 100644 index 000000000..f3c9b0880 --- /dev/null +++ b/contrib/android/p4a_recipes/qt5/__init__.py @@ -0,0 +1,8 @@ +from pythonforandroid.recipes.qt5 import Qt5Recipe + + +assert Qt5Recipe._version == "9b43a43ee96198674060c6b9591e515e2d27c28f" +assert Qt5Recipe.depends == ['python3'] +assert Qt5Recipe.python_depends == [] + +recipe = Qt5Recipe()