872e37b572
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.
534 lines
21 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|