qml: add initial bolt-11/bip-21 chooser in requestdialog
implement proper placement of icon over qr code fix urlencoding in qr imageprovider
This commit is contained in:
@@ -5,6 +5,8 @@ import QtQuick.Controls.Material 2.0
|
|||||||
|
|
||||||
import org.electrum 1.0
|
import org.electrum 1.0
|
||||||
|
|
||||||
|
import "controls"
|
||||||
|
|
||||||
Dialog {
|
Dialog {
|
||||||
id: dialog
|
id: dialog
|
||||||
title: qsTr('Payment Request')
|
title: qsTr('Payment Request')
|
||||||
@@ -12,6 +14,7 @@ Dialog {
|
|||||||
property var modelItem
|
property var modelItem
|
||||||
|
|
||||||
property string _bip21uri
|
property string _bip21uri
|
||||||
|
property string _bolt11
|
||||||
|
|
||||||
parent: Overlay.overlay
|
parent: Overlay.overlay
|
||||||
modal: true
|
modal: true
|
||||||
@@ -44,55 +47,96 @@ Dialog {
|
|||||||
clip:true
|
clip:true
|
||||||
interactive: height < contentHeight
|
interactive: height < contentHeight
|
||||||
|
|
||||||
GridLayout {
|
ColumnLayout {
|
||||||
id: rootLayout
|
id: rootLayout
|
||||||
width: parent.width
|
width: parent.width
|
||||||
rowSpacing: constants.paddingMedium
|
spacing: constants.paddingMedium
|
||||||
columns: 5
|
|
||||||
|
states: [
|
||||||
|
State {
|
||||||
|
name: 'bolt11'
|
||||||
|
PropertyChanges { target: qrloader; sourceComponent: qri_bolt11 }
|
||||||
|
PropertyChanges { target: bolt11label; font.bold: true }
|
||||||
|
},
|
||||||
|
State {
|
||||||
|
name: 'bip21uri'
|
||||||
|
PropertyChanges { target: qrloader; sourceComponent: qri_bip21uri }
|
||||||
|
PropertyChanges { target: bip21label; font.bold: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 1
|
height: 1
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.columnSpan: 5
|
|
||||||
color: Material.accentColor
|
color: Material.accentColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Image {
|
Item {
|
||||||
id: qr
|
|
||||||
Layout.columnSpan: 5
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
Layout.topMargin: constants.paddingSmall
|
Layout.topMargin: constants.paddingSmall
|
||||||
Layout.bottomMargin: constants.paddingSmall
|
Layout.bottomMargin: constants.paddingSmall
|
||||||
|
|
||||||
Rectangle {
|
Layout.preferredWidth: qrloader.width
|
||||||
property int size: 57 // should be qr pixel multiple
|
Layout.preferredHeight: qrloader.height
|
||||||
color: 'white'
|
|
||||||
x: (parent.width - size) / 2
|
|
||||||
y: (parent.height - size) / 2
|
|
||||||
width: size
|
|
||||||
height: size
|
|
||||||
|
|
||||||
Image {
|
Loader {
|
||||||
|
id: qrloader
|
||||||
source: '../../icons/electrum.png'
|
Component {
|
||||||
x: 1
|
id: qri_bip21uri
|
||||||
y: 1
|
QRImage {
|
||||||
width: parent.width - 2
|
qrdata: _bip21uri
|
||||||
height: parent.height - 2
|
}
|
||||||
scale: 0.9
|
|
||||||
}
|
}
|
||||||
|
Component {
|
||||||
|
id: qri_bolt11
|
||||||
|
QRImage {
|
||||||
|
qrdata: _bolt11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: {
|
||||||
|
if (rootLayout.state == 'bolt11') {
|
||||||
|
if (_bip21uri != '')
|
||||||
|
rootLayout.state = 'bip21uri'
|
||||||
|
} else if (rootLayout.state == 'bip21uri') {
|
||||||
|
if (_bolt11 != '')
|
||||||
|
rootLayout.state = 'bolt11'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
spacing: constants.paddingLarge
|
||||||
|
Label {
|
||||||
|
id: bolt11label
|
||||||
|
text: qsTr('BOLT11')
|
||||||
|
color: _bolt11 ? Material.foreground : constants.mutedForeground
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: constants.paddingXXSmall
|
||||||
|
Layout.preferredHeight: constants.paddingXXSmall
|
||||||
|
radius: constants.paddingXXSmall / 2
|
||||||
|
color: Material.accentColor
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
id: bip21label
|
||||||
|
text: qsTr('BIP21 URI')
|
||||||
|
color: _bip21uri ? Material.foreground : constants.mutedForeground
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 1
|
height: 1
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.columnSpan: 5
|
|
||||||
color: Material.accentColor
|
color: Material.accentColor
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.columnSpan: 5
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
Button {
|
Button {
|
||||||
icon.source: '../../icons/delete.png'
|
icon.source: '../../icons/delete.png'
|
||||||
@@ -127,80 +171,84 @@ Dialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Label {
|
|
||||||
visible: modelItem.message != ''
|
|
||||||
text: qsTr('Description')
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: modelItem.message != ''
|
|
||||||
Layout.columnSpan: 4
|
|
||||||
Layout.fillWidth: true
|
|
||||||
wrapMode: Text.Wrap
|
|
||||||
text: modelItem.message
|
|
||||||
font.pixelSize: constants.fontSizeLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
GridLayout {
|
||||||
visible: modelItem.amount.satsInt != 0
|
columns: 2
|
||||||
text: qsTr('Amount')
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: modelItem.amount.satsInt != 0
|
|
||||||
text: Config.formatSats(modelItem.amount)
|
|
||||||
font.family: FixedFont
|
|
||||||
font.pixelSize: constants.fontSizeLarge
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: modelItem.amount.satsInt != 0
|
|
||||||
text: Config.baseUnit
|
|
||||||
color: Material.accentColor
|
|
||||||
font.pixelSize: constants.fontSizeLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
id: fiatValue
|
visible: modelItem.message != ''
|
||||||
visible: modelItem.amount.satsInt != 0
|
text: qsTr('Description')
|
||||||
Layout.fillWidth: true
|
}
|
||||||
Layout.columnSpan: 2
|
Label {
|
||||||
text: Daemon.fx.enabled
|
visible: modelItem.message != ''
|
||||||
? '(' + Daemon.fx.fiatValue(modelItem.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')'
|
Layout.fillWidth: true
|
||||||
: ''
|
wrapMode: Text.Wrap
|
||||||
font.pixelSize: constants.fontSizeMedium
|
text: modelItem.message
|
||||||
wrapMode: Text.Wrap
|
font.pixelSize: constants.fontSizeLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: qsTr('Address')
|
visible: modelItem.amount.satsInt != 0
|
||||||
visible: !modelItem.is_lightning
|
text: qsTr('Amount')
|
||||||
}
|
}
|
||||||
Label {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Label {
|
||||||
Layout.columnSpan: 3
|
visible: modelItem.amount.satsInt != 0
|
||||||
visible: !modelItem.is_lightning
|
text: Config.formatSats(modelItem.amount)
|
||||||
font.family: FixedFont
|
font.family: FixedFont
|
||||||
font.pixelSize: constants.fontSizeLarge
|
font.pixelSize: constants.fontSizeLarge
|
||||||
wrapMode: Text.WrapAnywhere
|
font.bold: true
|
||||||
text: modelItem.address
|
}
|
||||||
}
|
Label {
|
||||||
ToolButton {
|
visible: modelItem.amount.satsInt != 0
|
||||||
icon.source: '../../icons/copy_bw.png'
|
text: Config.baseUnit
|
||||||
visible: !modelItem.is_lightning
|
color: Material.accentColor
|
||||||
onClicked: {
|
font.pixelSize: constants.fontSizeLarge
|
||||||
AppController.textToClipboard(modelItem.address)
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: fiatValue
|
||||||
|
visible: modelItem.amount.satsInt != 0
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: Daemon.fx.enabled
|
||||||
|
? '(' + Daemon.fx.fiatValue(modelItem.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')'
|
||||||
|
: ''
|
||||||
|
font.pixelSize: constants.fontSizeMedium
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: qsTr('Address')
|
||||||
|
visible: !modelItem.is_lightning
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
visible: !modelItem.is_lightning
|
||||||
|
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: {
|
||||||
|
AppController.textToClipboard(modelItem.address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: qsTr('Status')
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
font.pixelSize: constants.fontSizeLarge
|
||||||
|
text: modelItem.status_str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
|
||||||
text: qsTr('Status')
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
Layout.columnSpan: 4
|
|
||||||
Layout.fillWidth: true
|
|
||||||
font.pixelSize: constants.fontSizeLarge
|
|
||||||
text: modelItem.status_str
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,9 +264,14 @@ Dialog {
|
|||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (!modelItem.is_lightning) {
|
if (!modelItem.is_lightning) {
|
||||||
_bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp)
|
_bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp)
|
||||||
qr.source = 'image://qrgen/' + _bip21uri
|
rootLayout.state = 'bip21uri'
|
||||||
} else {
|
} else {
|
||||||
qr.source = 'image://qrgen/' + modelItem.lightning_invoice
|
_bolt11 = modelItem.lightning_invoice
|
||||||
|
rootLayout.state = 'bolt11'
|
||||||
|
if (modelItem.address != '') {
|
||||||
|
_bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp)
|
||||||
|
console.log('BIP21:' + _bip21uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,29 +51,11 @@ Dialog {
|
|||||||
color: Material.accentColor
|
color: Material.accentColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Image {
|
QRImage {
|
||||||
id: qr
|
id: qr
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
Layout.topMargin: constants.paddingSmall
|
Layout.topMargin: constants.paddingSmall
|
||||||
Layout.bottomMargin: 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 {
|
Rectangle {
|
||||||
@@ -114,6 +96,6 @@ Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
qr.source = 'image://qrgen/' + dialog.text
|
qr.qrdata = dialog.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
electrum/gui/qml/components/controls/QRImage.qml
Normal file
25
electrum/gui/qml/components/controls/QRImage.qml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import QtQuick 2.6
|
||||||
|
|
||||||
|
Image {
|
||||||
|
property string qrdata
|
||||||
|
|
||||||
|
source: qrdata ? 'image://qrgen/' + qrdata : ''
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
property var qrprops: QRIP.getDimensions(qrdata)
|
||||||
|
color: 'white'
|
||||||
|
x: (parent.width - width) / 2
|
||||||
|
y: (parent.height - height) / 2
|
||||||
|
width: qrprops.icon_modules * qrprops.box_size
|
||||||
|
height: qrprops.icon_modules * qrprops.box_size
|
||||||
|
|
||||||
|
Image {
|
||||||
|
source: '../../../icons/electrum.png'
|
||||||
|
x: 1
|
||||||
|
y: 1
|
||||||
|
width: parent.width - 2
|
||||||
|
height: parent.height - 2
|
||||||
|
scale: 0.9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ from .qeconfig import QEConfig
|
|||||||
from .qedaemon import QEDaemon, QEWalletListModel
|
from .qedaemon import QEDaemon, QEWalletListModel
|
||||||
from .qenetwork import QENetwork
|
from .qenetwork import QENetwork
|
||||||
from .qewallet import QEWallet
|
from .qewallet import QEWallet
|
||||||
from .qeqr import QEQRParser, QEQRImageProvider
|
from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper
|
||||||
from .qewalletdb import QEWalletDB
|
from .qewalletdb import QEWalletDB
|
||||||
from .qebitcoin import QEBitcoin
|
from .qebitcoin import QEBitcoin
|
||||||
from .qefx import QEFX
|
from .qefx import QEFX
|
||||||
@@ -166,6 +166,7 @@ class ElectrumQmlApplication(QGuiApplication):
|
|||||||
|
|
||||||
self.qr_ip = QEQRImageProvider((7/8)*min(screensize.width(), screensize.height()))
|
self.qr_ip = QEQRImageProvider((7/8)*min(screensize.width(), screensize.height()))
|
||||||
self.engine.addImageProvider('qrgen', self.qr_ip)
|
self.engine.addImageProvider('qrgen', self.qr_ip)
|
||||||
|
self.qr_ip_h = QEQRImageProviderHelper((7/8)*min(screensize.width(), screensize.height()))
|
||||||
|
|
||||||
# add a monospace font as we can't rely on device having one
|
# add a monospace font as we can't rely on device having one
|
||||||
self.fixedFont = 'PT Mono'
|
self.fixedFont = 'PT Mono'
|
||||||
@@ -187,6 +188,7 @@ class ElectrumQmlApplication(QGuiApplication):
|
|||||||
self.context.setContextProperty('Daemon', self._qedaemon)
|
self.context.setContextProperty('Daemon', self._qedaemon)
|
||||||
self.context.setContextProperty('FixedFont', self.fixedFont)
|
self.context.setContextProperty('FixedFont', self.fixedFont)
|
||||||
self.context.setContextProperty('MAX', self._maxAmount)
|
self.context.setContextProperty('MAX', self._maxAmount)
|
||||||
|
self.context.setContextProperty('QRIP', self.qr_ip_h)
|
||||||
self.context.setContextProperty('BUILD', {
|
self.context.setContextProperty('BUILD', {
|
||||||
'electrum_version': version.ELECTRUM_VERSION,
|
'electrum_version': version.ELECTRUM_VERSION,
|
||||||
'apk_version': version.APK_VERSION,
|
'apk_version': version.APK_VERSION,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QRect, QPoint
|
|
||||||
from PyQt5.QtGui import QImage,QColor
|
|
||||||
from PyQt5.QtQuick import QQuickImageProvider
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import qrcode
|
import qrcode
|
||||||
import math
|
import math
|
||||||
|
import urllib
|
||||||
|
|
||||||
from PIL import Image, ImageQt
|
from PIL import Image, ImageQt
|
||||||
|
|
||||||
|
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect, QPoint
|
||||||
|
from PyQt5.QtGui import QImage,QColor
|
||||||
|
from PyQt5.QtQuick import QQuickImageProvider
|
||||||
|
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.qrreader import get_qr_reader
|
from electrum.qrreader import get_qr_reader
|
||||||
from electrum.i18n import _
|
from electrum.i18n import _
|
||||||
@@ -126,17 +127,53 @@ class QEQRImageProvider(QQuickImageProvider):
|
|||||||
|
|
||||||
@profiler
|
@profiler
|
||||||
def requestImage(self, qstr, size):
|
def requestImage(self, qstr, size):
|
||||||
|
# Qt does a urldecode before passing the string here
|
||||||
|
# but BIP21 (and likely other uri based specs) requires urlencoding,
|
||||||
|
# so we re-encode percent-quoted if a 'scheme' is found in the string
|
||||||
|
uri = urllib.parse.urlparse(qstr)
|
||||||
|
if uri.scheme:
|
||||||
|
# urlencode request parameters
|
||||||
|
query = urllib.parse.parse_qs(uri.query)
|
||||||
|
query = urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote)
|
||||||
|
uri = uri._replace(query=query)
|
||||||
|
qstr = urllib.parse.urlunparse(uri)
|
||||||
|
|
||||||
self._logger.debug('QR requested for %s' % qstr)
|
self._logger.debug('QR requested for %s' % qstr)
|
||||||
qr = qrcode.QRCode(version=1, border=2)
|
qr = qrcode.QRCode(version=1, border=2)
|
||||||
qr.add_data(qstr)
|
qr.add_data(qstr)
|
||||||
|
|
||||||
# calculate best box_size
|
# calculate best box_size
|
||||||
pixelsize = min(self._max_size, 400)
|
pixelsize = min(self._max_size, 400)
|
||||||
modules = 17 + 4 * qr.best_fit()
|
modules = 17 + 4 * qr.best_fit() + qr.border * 2
|
||||||
qr.box_size = math.floor(pixelsize/(modules+2*2))
|
qr.box_size = math.floor(pixelsize/modules)
|
||||||
|
|
||||||
qr.make(fit=True)
|
qr.make(fit=True)
|
||||||
|
|
||||||
pimg = qr.make_image(fill_color='black', back_color='white')
|
pimg = qr.make_image(fill_color='black', back_color='white')
|
||||||
self.qimg = ImageQt.ImageQt(pimg)
|
self.qimg = ImageQt.ImageQt(pimg)
|
||||||
return self.qimg, self.qimg.size()
|
return self.qimg, self.qimg.size()
|
||||||
|
|
||||||
|
# helper for placing icon exactly where it should go on the QR code
|
||||||
|
# pyqt5 is unwilling to accept slots on QEQRImageProvider, so we need to define
|
||||||
|
# a separate class (sigh)
|
||||||
|
class QEQRImageProviderHelper(QObject):
|
||||||
|
def __init__(self, max_size, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._max_size = max_size
|
||||||
|
|
||||||
|
@pyqtSlot(str, result='QVariantMap')
|
||||||
|
def getDimensions(self, qstr):
|
||||||
|
qr = qrcode.QRCode(version=1, border=2)
|
||||||
|
qr.add_data(qstr)
|
||||||
|
|
||||||
|
# calculate best box_size
|
||||||
|
pixelsize = min(self._max_size, 400)
|
||||||
|
modules = 17 + 4 * qr.best_fit() + qr.border * 2
|
||||||
|
qr.box_size = math.floor(pixelsize/modules)
|
||||||
|
|
||||||
|
# calculate icon width in modules
|
||||||
|
icon_modules = int(modules / 5)
|
||||||
|
icon_modules += (icon_modules+1)%2 # force odd
|
||||||
|
|
||||||
|
return { 'modules': modules, 'box_size': qr.box_size, 'icon_modules': icon_modules }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user