1
0

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:
Sander van Grieken
2022-07-21 10:19:07 +02:00
parent f5b1f7d2d9
commit a970c0f78a
5 changed files with 221 additions and 122 deletions

View File

@@ -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)
}
} }
} }

View File

@@ -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
} }
} }

View 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
}
}
}

View File

@@ -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,

View File

@@ -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 }