Files
pay/modules/build-transaction/PayToOthers.qml
T
tomFlowee 872e37b572 Make notes properties more declarative
This changes the notes property to be only set by the user of the
BroadcastFeedback.qml, and not from inside anymore which could break the
property binding causing strange things to happen.
2025-03-16 22:14:20 +01:00

534 lines
21 KiB
QML

/*
* This file is part of the Flowee project
* Copyright (C) 2022-2025 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
onApprovedByUser: {
thePile.currentItem.hideHeader = true;
broadcastPage.start();
}
}
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()
broadcastPage.personalNote = payment.userComment
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 desination not being set");
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
DestinationEditPage { }
}
/*
* 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: 10
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
radius: 6
Image {
id: trashcan
source: "./recycle" + (Pay.useDarkSkin ? "-light" : "") + ".svg"
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")
pageButton: true
onClicked: pushToThePile(destinationEditPage, payment.addExtraOutput());
}
/*TextButton {
text: qsTr("Add Detail...")
pageButton: 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 {
id: broadcastPage
bitcoinAmount: payment.paymentAmount
fiatPrice: payment.fiatPrice
targetAddress: payment.niceAddress
status: payment.broadcastStatus
processNote: function process(note) {
payment.userComment = note
}
onCloseButtonPressed: {
var mainView = thePile.get(0);
mainView.currentIndex = 0; // go to the 'main' tab.
thePile.pop();
}
}
}