Files
pay/modules/build-transaction/PayToOthers.qml
T
tomFlowee 2e71a162aa Tweak and fix clipboard paste based workflows.
One surprise was that the main usecase of pasting is one where the user
activates another app to go and copy data in order to come back to paste
it.
And the Qt clipboard didn't manage to get any notification of clipboard
changes. Even copying data on becoming active had no effect.

So now I just show the paste optimistically when the user comes back
from another screen, assuming the main reason for that was to copy.
2023-10-17 12:02:24 +02:00

722 lines
29 KiB
QML

/*
* This file is part of the Flowee project
* Copyright (C) 2022-2023 Tom Zander <tom@flowee.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import QtQuick;
import QtQuick.Layouts;
import QtQuick.Controls as QQC2;
import "../Flowee" as Flowee;
import "../mobile";
import Flowee.org.pay;
Page {
id: root
headerText: qsTr("Build Transaction")
Item { // data and non-visible stuff for this page
/*
* Components embedded in this file:
* Editors:
destinationEditPage
editors can use the property 'paymentDetail'
* ListItems:
destinationFields
list items have the property 'edit' to link to Editors
property Component edit: [something]
list items can use the property 'paymentDetail'
* Other:
paymentDetailSelector
paymentInfoPage
* To open editors or list items, we use the pushToThePile() function.
*/
Payment {
id: payment
account: portfolio.current
fiatPrice: Fiat.price
}
Flowee.Dialog {
id: errorDialog
standardButtons: QQC2.DialogButtonBox.Close
title: qsTr("Building Error", "error during build")
text: payment.error
}
// Extra page to create new details.
Component {
id: paymentDetailSelector
Page {
headerText: qsTr("Add Payment Detail", "page title")
Flickable {
anchors.fill: parent
contentHeight: col.height
Column {
id: col
width: parent.width
TextButton {
text: qsTr("Add Destination")
subtext: qsTr("an address to send money to")
onClicked: {
var detail = payment.addExtraOutput();
thePile.pop();
pushToThePile(destinationEditPage,
detail);
}
}
}
}
}
}
// show info about payment and allow broadcast
Component {
id: paymentInfoPage
Page {
id: root
headerText: qsTr("Confirm Sending", "confirm we want to send the transaction")
Flickable {
anchors.top: parent.top
anchors.bottom: slideToApprove.top
width: parent.width
contentHeight: col.implicitHeight
clip: true
Column {
id: col
width: parent.width
spacing: 10
// the below is all very technical stuff that most people don't care about.
// it was easy, but its not useful.
// So, TODO insert here actually useful to end users details like amount sent,
// change address used. Etc.
Flowee.Label {
text: qsTr("TXID") + ":"
}
Flowee.LabelWithClipboard {
id: txid
text: payment.txid
font.pixelSize: root.font.pixelSize * 0.8
width: parent.width
// Change the color when the portfolio changed since 'prepare' was clicked.
menuText: qsTr("Copy transaction-ID")
}
Flowee.Label {
text: qsTr("Fee") + ":"
}
Flowee.BitcoinAmountLabel {
value: payment.assignedFee
colorize: false
}
Flowee.Label {
text: qsTr("Transaction size") + ":"
}
Flowee.Label {
text: qsTr("%1 bytes").arg(payment.txSize)
}
Flowee.Label {
text: qsTr("Fee per byte") + ":"
}
Flowee.Label {
text: {
var rc = payment.assignedFee / payment.txSize;
var fee = rc.toFixed(3); // no more than 3 numbers behind the separator
fee = (fee * 1.0).toString(); // remove trailing zero's (1.000 => 1)
return qsTr("%1 sat/byte", "fee").arg(fee);
}
}
}
}
SlideToApprove {
id: slideToApprove
anchors.bottom: parent.bottom
anchors.bottomMargin: 10
width: parent.width
onActivated: {
payment.markUserApproved()
thePile.pop(); // the broadcast feedback is on the main screen.
}
}
}
}
// listitem of PaymentDetail showing payment-destination
Component {
id: destinationFields
Column {
property Component edit: destinationEditPage
width: parent.width
spacing: 6
/* This page just shows the results, editing is done
* on its own page.
*/
PageTitledBox {
width: parent.width
title: qsTr("Destination")
Flowee.LabelWithClipboard {
width: parent.width
font.italic: paymentDetail.address === ""
text: {
var s = paymentDetail.niceAddress
if (s === "") {
if (paymentDetail.address === "") // the user-specified text is empty
return qsTr("unset", "indication of empty");
return qsTr("invalid", "address is not correct");
}
return s;
}
color: addressInfo.addressOk || paymentDetail.address === ""
? palette.windowText : mainWindow.errorRed
font.pixelSize: mainWindow.font.pixelSize * 0.9
menuText: qsTr("Copy Address")
clipboardText: paymentDetail.formattedTarget // the one WITH bitcoincash:
}
Flowee.AddressInfoWidget {
id: addressInfo
width: parent.width
addressType: Pay.identifyString(paymentDetail.address);
}
Flowee.BitcoinAmountLabel {
value: paymentDetail.paymentAmount
colorize: false
}
}
}
}
/*
* The different payment things work with a 'paymentDetail'
* and since we push those into the global stack, they get
* loaded and initialized first, only secondly we set the
* property paymentDetail on them.
*
* This means we either get a load of errors dereferencing a null
* object, or we need to alter a lot of code to account for that.
*
* This is the alternative solution: we add a layer of indirection
* and set the property on the loader and the item in the loader
* will simply find the paymentDetail present in the context in
* which it has been loaded.
*/
Component {
id: loaderForPayments
FocusScope {
property alias paymentDetail: loader2.paymentDetail
property Component sourceComponent: undefined
function takeFocus() {
// this is also present in 'page', and called from thePile
forceActiveFocus();
}
Loader {
property QtObject paymentDetail: null
id: loader2
anchors.fill: parent
onLoaded: item.takeFocus();
}
function load() {
if (paymentDetail != null && typeof sourceComponent != "undefined") {
loader2.sourceComponent = sourceComponent
}
}
onPaymentDetailChanged: load();
onSourceComponentChanged: load();
}
}
Component {
id: destinationEditPage
Page {
headerText: qsTr("Edit Destination")
property QtObject sendAllAction: QQC2.Action {
checkable: true
checked: paymentDetail.maxSelected
text: qsTr("Send All", "all money in wallet")
onTriggered: paymentDetail.maxSelected = checked
}
Flowee.Label {
id: destinationLabel
text: qsTr("Bitcoin Cash Address") + ":"
}
Flowee.MultilineTextField {
id: destination
anchors.top: destinationLabel.bottom
anchors.topMargin: 10
anchors.left: parent.left
anchors.right: parent.right
height: Math.max(destinationLabel.height * 2.3, implicitHeight)
focus: enabled
property var addressType: Pay.identifyString(totalText);
text: paymentDetail.address
nextFocusTarget: priceInput
enabled: paymentDetail.editable
onTotalTextChanged: {
paymentDetail.address = totalText
addressInfo.createInfo();
}
color: {
if (!activeFocus && totalText !== "" && !addressInfo.addressOk)
return mainWindow.errorRed
return palette.windowText
}
}
Rectangle {
id: pasteButton
property bool appWasHidden: false
anchors.verticalCenter: destination.bottom
anchors.horizontalCenter: destination.horizontalCenter
width: labelText.height + labelText.width + 20 + 5 + 20
height: labelText.height + 10
radius: 6
color: palette.window
border.color: palette.midlight
border.width: 1
visible: destination.totalText === "" && (cbh.text !== "" || appWasHidden)
Connections {
/*
* A usecase of pasting is that the user opens this screen, goes to another app and
* copies something they want to copy and then comes back here to paste it.
* This causes the 'appWasHidden' to be set to true and we then 'enable' the clipboardHelper
* in order to read the clipboard-data.
*
* Notice that this is needed because the clipboard doesn't notice something new being
* present after we just got unfrozen.
*/
target: Application
function onActiveChanged() {
if (Application.active !== Qt.ApplicationActive) {
pasteButton.appWasHidden = true;
cbh.enabled = false;
}
}
}
ClipboardHelper {
id: cbh
filter: ClipboardHelper.Addresses + ClipboardHelper.LegacyAddresses
}
Image {
x: 20
width: labelText.height
height: width
source: "qrc:/edit-clipboard" + (Pay.useDarkSkin ? "-light.svg" : ".svg");
anchors.verticalCenter: parent.verticalCenter
}
Flowee.Label {
id: labelText
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 20
text: qsTr("Paste")
}
MouseArea {
anchors.fill: parent
onClicked: {
if (pasteButton.appWasHidden) {
pasteButton.appWasHidden = false;
cbh.enabled = true; // fills text, if there is something to fill.
}
if (cbh.text !== "") {
destination.text = cbh.text
priceInput.takeFocus();
}
}
}
}
Flowee.LabelWithClipboard {
id: nativeLabel
width: parent.width
anchors.top: destination.bottom
anchors.topMargin: 10
// only show if its substantially different
visible: text!== "" && text !== destination.text && destination.text !== paymentDetail.formattedTarget
text: paymentDetail.niceAddress
clipboardText: paymentDetail.formattedTarget // the one WITH bitcoincash:
font.italic: true
menuText: qsTr("Copy Address")
}
Flowee.AddressInfoWidget {
id: addressInfo
anchors.top: nativeLabel.visible ? nativeLabel.bottom : destination.bottom
width: parent.width
addressType: destination.addressType
}
PriceInputWidget {
id: priceInput
width: parent.width
anchors.top: addressInfo.bottom
paymentBackend: paymentDetail
fiatFollowsSats: paymentDetail.fiatFollows
onFiatFollowsSatsChanged: paymentDetail.fiatFollows = fiatFollowsSats
}
AccountSelectorWidget {
id: walletNameBackground
anchors.bottom: numericKeyboard.top
anchors.bottomMargin: 10
stickyAccount: true
balanceActions: [ sendAllAction ]
}
NumericKeyboardWidget {
id: numericKeyboard
anchors.bottom: parent.bottom
anchors.bottomMargin: 15
width: parent.width
enabled: !paymentDetail.maxSelected
dataInput: priceInput
}
Rectangle {
color: mainWindow.errorRedBg
radius: 15
width: parent.width
height: warningColumn.height + 20
anchors.top: nativeLabel.bottom
// BTC address entered warning.
visible: (destination.addressType === Wallet.LegacySH
|| destination.addressType === Wallet.LegacyPKH)
&& paymentDetail.forceLegacyOk === false;
Column {
id: warningColumn
x: 10
y: 10
width: parent.width - 20
spacing: 10
Flowee.Label {
font.bold: true
font.pixelSize: warning.font.pixelSize * 1.2
text: qsTr("Warning")
color: "white"
anchors.horizontalCenter: parent.horizontalCenter
}
Flowee.Label {
id: warning
width: parent.width
color: "white"
text: qsTr("This is a BTC address, which is an incompatible coin. Your funds could get lost and Flowee will have no way to recover them. Are you sure this is the right address?")
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
Item {
width: parent.width
height: okButton.height
QQC2.Button {
id: okButton
anchors.right: parent.right
text: qsTr("I am certain")
onClicked: paymentDetail.forceLegacyOk = true
}
}
}
}
}
}
/*
* A helper page that allows unlocking an account prior to paying from it.
*/
Component {
id: unlockInPage
Page {
headerText: payment.account.name
UnlockWalletPanel {
anchors.fill: parent
anchors.margins: 10
account: payment.account
Connections {
target: payment.account
function onIsDecryptedChanged() {
if (payment.account.isDecrypted)
thePile.pop()
}
}
}
}
}
}
// check the comment at loaderForPayments to understand this one
function pushToThePile(componentId, detail) {
thePile.push(loaderForPayments,
{"paymentDetail": detail,
"sourceComponent": componentId }
);
}
Flickable {
id: contentArea
anchors.fill: parent
contentHeight: mainColumn.height + 10
contentWidth: width
clip: true
Column {
id: mainColumn
y: 10
width: parent.width
spacing: 6
AccountSelectorWidget {
visible: !portfolio.singleAccountSetup
}
VisualSeparator {
visible: !portfolio.singleAccountSetup
}
Repeater {
model: payment.details
delegate: Item {
id: listItem
width: mainColumn.width
height: loader.height + 20 + (index <= 1 ? (dragInstructions.height + 20) : 0)
Loader {
id: loader
width: parent.width
height: status === Loader.Ready ? item.implicitHeight : 0
property QtObject paymentDetail: modelData
sourceComponent: {
if (modelData.type === Payment.PayToAddress)
return destinationFields
if (modelData.type === Payment.InputSelector)
return inputFields
return null; // should never happen
}
}
Item {
// an invisible item that is used to handle the drag events.
id: dragItem
width: parent.width
height: parent.height
property bool editStarted: false
onXChanged: {
/* When we move, we move the icons etc with us.
*/
if (x < 0) {
leftArea.x = leftArea.width * -1 // out of screen
// moving left, opening the edit option
loader.x = x;
let a = x * -1;
editIcon.x = width - a + Math.max(0, a - editIcon.width);
if (!editStarted && x < -100) {
editStarted = true;
pushToThePile(loader.item.edit, modelData);
}
}
else {
editIcon.x = width;
// moving right, opening the trashcan option
let a = x;
let base = Math.min(40, a);
let rest = a - base;
deleteTimer.running = rest > 90;
if (rest < 90)
deleteTimer.deleteTriggered = false
leftBackground.opacity = Math.max(rest - 40) / 100;
rest = Math.min(60, rest) / 4; // slower
leftArea.x = leftArea.width * -1 + base + rest;
loader.x = base + rest;
}
}
DragHandler {
id: dragHandler
// property bool editStarted: false;
yAxis.enabled: false
xAxis.enabled: true
xAxis.maximum: index > 0 ? 200 : 0 // swipe left
xAxis.minimum: -140 // swipe right
onActiveChanged: {
if (active) {
parent.editStarted = false;
return;
}
// take action on user releasing the drag.
if (deleteTimer.deleteTriggered) {
deleteTimer.deleteTriggered = false;
payment.remove(modelData);
}
parent.x = 0;
}
}
}
// delete-detail interface
Item {
id: leftArea
width: 300
height: loader.height
x: -width;
Rectangle {
id: leftBackground
opacity: 0
anchors.fill: parent
color: mainWindow.errorRedBg
}
Rectangle {
id: trashcan
color: "orange" // TODO replace this with an icon
width: 40
height: 40
y: 10
x: {
let newX = parent.width - width;
let moved = parent.x + parent.width;
let additional = Math.max(0, Math.min(moved - width, 8));
return newX - additional;
}
}
Timer {
/*
The intention of this timer is that the user can't just swipe hard
and suddenly lose their data.
We need to have the user actually see the red for half a second
before we delete.
*/
id: deleteTimer
interval: 400
property bool deleteTriggered: false
onTriggered: deleteTriggered = true
}
}
Image {
id: editIcon
width: 40
height: 40
y: 10
x: {
let additional = Math.max(0, Math.min(listItem.x * -1 - width, 16))
return parent.width + additional;
}
source: "./edit" + (Pay.useDarkSkin ? "-light" : "") + ".svg"
smooth: true
}
// UX help
Row {
id: row
anchors.bottom: parent.bottom
anchors.bottomMargin: 26
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
visible: index <= 1
property bool dragToEdit: index === 0
Repeater {
model: 4
delegate: Flowee.ArrowPoint {
color: palette.highlight
anchors.verticalCenter: dragInstructions.verticalCenter
rotation: row.dragToEdit ? 180 : 0;
}
}
Flowee.Label {
id: dragInstructions
text: index === 0 ? qsTr("Drag to Edit") : qsTr("Drag to Delete");
font.italic: true
color: palette.highlight
}
Repeater {
model: 4
delegate: Flowee.ArrowPoint {
color: palette.highlight
anchors.verticalCenter: dragInstructions.verticalCenter
rotation: row.dragToEdit ? 180 : 0;
}
}
}
VisualSeparator {
anchors.bottom: parent.bottom
}
Behavior on x { NumberAnimation { duration: 100 } }
}
}
TextButton {
text: qsTr("Add Destination")
showPageIcon: true
onClicked: pushToThePile(destinationEditPage, payment.addExtraOutput());
}
/*
TextButton {
text: qsTr("Add Detail...")
showPageIcon: true
onClicked: thePile.push(paymentDetailSelector);
}
*/
Flowee.Button {
property bool walletNeedsDecryptFirst: !payment.account.isDecrypted && payment.account.needsPinToPay;
text: walletNeedsDecryptFirst ? qsTr("Unlock Wallet") : qsTr("Prepare Payment...")
enabled: payment.isValid
anchors.right: parent.right
onClicked: {
if (walletNeedsDecryptFirst) {
thePile.push(unlockInPage);
return;
}
payment.prepare();
if (payment.error !== "") {
errorDialog.visible = true;
}
else {
thePile.push(paymentInfoPage);
}
}
}
}
}
Flowee.BroadcastFeedback {
anchors.leftMargin: -10 // go against the margins that Page gave us to show more fullscreen.
anchors.rightMargin: -10
onStatusChanged: {
if (status !== "")
root.headerText = status;
}
onCloseButtonPressed: {
var mainView = thePile.get(0);
mainView.currentIndex = 0; // go to the 'main' tab.
thePile.pop();
}
}
}