721 lines
29 KiB
QML
721 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();
|
|
}
|
|
}
|
|
}
|