diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index e9a0dd7f72..775bc28a96 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -45,6 +45,7 @@ QT_MOC_CPP = \ qml/models/moc_peerlistsortproxy.cpp \ qml/models/moc_walletlistmodel.cpp \ qml/moc_appmode.cpp \ + qml/moc_bitcoinamount.cpp \ qml/moc_walletcontroller.cpp \ qt/moc_addressbookpage.cpp \ qt/moc_addresstablemodel.cpp \ @@ -128,6 +129,7 @@ BITCOIN_QT_H = \ qml/models/walletlistmodel.h \ qml/appmode.h \ qml/bitcoin.h \ + qml/bitcoinamount.h \ qml/guiconstants.h \ qml/imageprovider.h \ qml/util.h \ @@ -308,6 +310,7 @@ BITCOIN_QT_WALLET_CPP = \ BITCOIN_QML_BASE_CPP = \ qml/bitcoin.cpp \ + qml/bitcoinamount.cpp \ qml/components/blockclockdial.cpp \ qml/controls/linegraph.cpp \ qml/models/chainmodel.cpp \ @@ -337,9 +340,11 @@ QML_RES_ICONS = \ qml/res/icons/caret-left.png \ qml/res/icons/caret-right.png \ qml/res/icons/check.png \ + qml/res/icons/copy.png \ qml/res/icons/cross.png \ qml/res/icons/error.png \ qml/res/icons/export.png \ + qml/res/icons/flip-vertical.png \ qml/res/icons/gear.png \ qml/res/icons/gear-outline.png \ qml/res/icons/hidden.png \ @@ -348,6 +353,7 @@ QML_RES_ICONS = \ qml/res/icons/network-dark.png \ qml/res/icons/network-light.png \ qml/res/icons/plus.png \ + qml/res/icons/pending.png \ qml/res/icons/shutdown.png \ qml/res/icons/singlesig-wallet.png \ qml/res/icons/storage-dark.png \ @@ -390,6 +396,7 @@ QML_RES_QML = \ qml/controls/InformationPage.qml \ qml/controls/IPAddressValueInput.qml \ qml/controls/KeyValueRow.qml \ + qml/controls/LabeledTextInput.qml \ qml/controls/NavButton.qml \ qml/controls/PageIndicator.qml \ qml/controls/NavigationBar.qml \ @@ -437,6 +444,8 @@ QML_RES_QML = \ qml/pages/wallet/CreatePassword.qml \ qml/pages/wallet/CreateWalletWizard.qml \ qml/pages/wallet/DesktopWallets.qml \ + qml/pages/wallet/RequestConfirmation.qml \ + qml/pages/wallet/RequestPayment.qml \ qml/pages/wallet/WalletBadge.qml \ qml/pages/wallet/WalletSelect.qml diff --git a/src/qml/bitcoin.cpp b/src/qml/bitcoin.cpp index 0e5d0f9ce7..7b641c3054 100644 --- a/src/qml/bitcoin.cpp +++ b/src/qml/bitcoin.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #ifdef __ANDROID__ #include #endif @@ -317,6 +318,7 @@ int QmlGuiMain(int argc, char* argv[]) qmlRegisterType("org.bitcoincore.qt", 1, 0, "BlockClockDial"); qmlRegisterType("org.bitcoincore.qt", 1, 0, "LineGraph"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "PeerDetailsModel", ""); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "BitcoinAmount"); engine.load(QUrl(QStringLiteral("qrc:///qml/pages/main.qml"))); diff --git a/src/qml/bitcoin_qml.qrc b/src/qml/bitcoin_qml.qrc index 61d2607fca..b82aeed18e 100644 --- a/src/qml/bitcoin_qml.qrc +++ b/src/qml/bitcoin_qml.qrc @@ -23,6 +23,7 @@ controls/ContinueButton.qml controls/CoreText.qml controls/CoreTextField.qml + controls/LabeledTextInput.qml controls/ExternalLink.qml controls/FocusBorder.qml controls/Header.qml @@ -77,6 +78,8 @@ pages/wallet/CreatePassword.qml pages/wallet/CreateWalletWizard.qml pages/wallet/DesktopWallets.qml + pages/wallet/RequestConfirmation.qml + pages/wallet/RequestPayment.qml pages/wallet/WalletBadge.qml pages/wallet/WalletSelect.qml @@ -93,9 +96,11 @@ res/icons/caret-left.png res/icons/caret-right.png res/icons/check.png + res/icons/copy.png res/icons/cross.png res/icons/error.png res/icons/export.png + res/icons/flip-vertical.png res/icons/gear.png res/icons/gear-outline.png res/icons/hidden.png @@ -104,6 +109,7 @@ res/icons/network-dark.png res/icons/network-light.png res/icons/plus.png + res/icons/pending.png res/icons/shutdown.png res/icons/singlesig-wallet.png res/icons/storage-dark.png diff --git a/src/qml/bitcoinamount.cpp b/src/qml/bitcoinamount.cpp new file mode 100644 index 0000000000..9abd30ad6d --- /dev/null +++ b/src/qml/bitcoinamount.cpp @@ -0,0 +1,127 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + + +BitcoinAmount::BitcoinAmount(QObject *parent) : QObject(parent) +{ + m_unit = Unit::BTC; +} + +int BitcoinAmount::decimals(Unit unit) +{ + switch (unit) { + case Unit::BTC: return 8; + case Unit::SAT: return 0; + } // no default case, so the compiler can warn about missing cases + assert(false); +} + +QString BitcoinAmount::sanitize(const QString &text) +{ + QString result = text; + + // Remove any invalid characters + result.remove(QRegExp("[^0-9.]")); + + // Ensure only one decimal point + QStringList parts = result.split('.'); + if (parts.size() > 2) { + result = parts[0] + "." + parts[1]; + } + + // Limit decimal places to 8 + if (parts.size() == 2 && parts[1].length() > 8) { + result = parts[0] + "." + parts[1].left(8); + } + + return result; +} + +BitcoinAmount::Unit BitcoinAmount::unit() const +{ + return m_unit; +} + +void BitcoinAmount::setUnit(const Unit unit) +{ + m_unit = unit; + Q_EMIT unitChanged(); +} + +QString BitcoinAmount::unitLabel() const +{ + switch (m_unit) { + case Unit::BTC: return "₿"; + case Unit::SAT: return "Sat"; + } + assert(false); +} + +QString BitcoinAmount::amount() const +{ + return m_amount; +} + +void BitcoinAmount::setAmount(const QString& new_amount) +{ + m_amount = sanitize(new_amount); + Q_EMIT amountChanged(); +} + +long long BitcoinAmount::toSatoshis(QString& amount, const Unit unit) +{ + + int num_decimals = decimals(unit); + + QStringList parts = amount.remove(' ').split("."); + + QString whole = parts[0]; + QString decimals; + + if(parts.size() > 1) + { + decimals = parts[1]; + } + QString str = whole + decimals.leftJustified(num_decimals, '0', true); + + return str.toLongLong(); +} + +QString BitcoinAmount::convert(const QString &amount, Unit unit) +{ + if (amount == "") { + return amount; + } + + QString result = amount; + int decimalPosition = result.indexOf("."); + + if (decimalPosition == -1) { + decimalPosition = result.length(); + result.append("."); + } + + if (unit == Unit::BTC) { + int numDigitsAfterDecimal = result.length() - decimalPosition - 1; + if (numDigitsAfterDecimal < 8) { + result.append(QString(8 - numDigitsAfterDecimal, '0')); + } + result.remove(decimalPosition, 1); + } else if (unit == Unit::SAT) { + result.remove(decimalPosition, 1); + int newDecimalPosition = decimalPosition - 8; + if (newDecimalPosition < 1) { + result = QString("0").repeated(-newDecimalPosition) + result; + newDecimalPosition = 0; + } + result.insert(newDecimalPosition, "."); + } + + return result; +} diff --git a/src/qml/bitcoinamount.h b/src/qml/bitcoinamount.h new file mode 100644 index 0000000000..29615e4e6f --- /dev/null +++ b/src/qml/bitcoinamount.h @@ -0,0 +1,52 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_BITCOINAMOUNT_H +#define BITCOIN_QML_BITCOINAMOUNT_H + +#include +#include +#include + +class BitcoinAmount : public QObject +{ + Q_OBJECT + Q_PROPERTY(Unit unit READ unit WRITE setUnit NOTIFY unitChanged) + Q_PROPERTY(QString unitLabel READ unitLabel NOTIFY unitChanged) + Q_PROPERTY(QString amount READ amount WRITE setAmount NOTIFY amountChanged) + +public: + enum class Unit { + BTC, + SAT + }; + Q_ENUM(Unit) + + explicit BitcoinAmount(QObject *parent = nullptr); + + Unit unit() const; + void setUnit(Unit unit); + QString unitLabel() const; + QString amount() const; + void setAmount(const QString& new_amount); + +public Q_SLOTS: + QString sanitize(const QString &text); + QString convert(const QString &text, Unit unit); + +Q_SIGNALS: + void unitChanged(); + void unitLabelChanged(); + void amountChanged(); + +private: + long long toSatoshis(QString &amount, const Unit unit); + int decimals(Unit unit); + + Unit m_unit; + QString m_unitLabel; + QString m_amount; +}; + +#endif // BITCOIN_QML_BITCOINAMOUNT_H diff --git a/src/qml/controls/LabeledTextInput.qml b/src/qml/controls/LabeledTextInput.qml new file mode 100644 index 0000000000..efa82f98f4 --- /dev/null +++ b/src/qml/controls/LabeledTextInput.qml @@ -0,0 +1,60 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Item { + property alias labelText: label.text + property alias text: input.text + property alias placeholderText: input.placeholderText + property alias iconSource: icon.source + property alias customIcon: iconContainer.data + property alias enabled: input.enabled + + signal iconClicked + signal textEdited + + id: root + implicitHeight: label.height + input.height + + CoreText { + id: label + anchors.left: parent.left + anchors.top: parent.top + color: Theme.color.neutral7 + font.pixelSize: 15 + } + + TextField { + id: input + anchors.left: parent.left + anchors.right: iconContainer.left + anchors.bottom: parent.bottom + leftPadding: 0 + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: Theme.color.neutral7 + background: Item {} + onTextEdited: root.textEdited() + } + + Item { + id: iconContainer + anchors.right: parent.right + anchors.verticalCenter: input.verticalCenter + + Icon { + id: icon + source: "" + color: Theme.color.neutral8 + size: 30 + enabled: source != "" + onClicked: root.iconClicked() + } + } +} diff --git a/src/qml/imageprovider.cpp b/src/qml/imageprovider.cpp index daf2feeae2..e1753b3b3c 100644 --- a/src/qml/imageprovider.cpp +++ b/src/qml/imageprovider.cpp @@ -77,6 +77,11 @@ QPixmap ImageProvider::requestPixmap(const QString& id, QSize* size, const QSize return QIcon(":/icons/check").pixmap(requested_size); } + if (id == "copy") { + *size = requested_size; + return QIcon(":/icons/copy").pixmap(requested_size); + } + if (id == "cross") { *size = requested_size; return QIcon(":/icons/cross").pixmap(requested_size); @@ -92,6 +97,11 @@ QPixmap ImageProvider::requestPixmap(const QString& id, QSize* size, const QSize return QIcon(":/icons/export").pixmap(requested_size); } + if (id == "flip-vertical") { + *size = requested_size; + return QIcon(":/icons/flip-vertical").pixmap(requested_size); + } + if (id == "gear") { *size = requested_size; return QIcon(":/icons/gear").pixmap(requested_size); @@ -122,6 +132,11 @@ QPixmap ImageProvider::requestPixmap(const QString& id, QSize* size, const QSize return QIcon(":/icons/network-light").pixmap(requested_size); } + if (id == "pending") { + *size = requested_size; + return QIcon(":/icons/pending").pixmap(requested_size); + } + if (id == "shutdown") { *size = requested_size; return QIcon(":/icons/shutdown").pixmap(requested_size); diff --git a/src/qml/pages/wallet/DesktopWallets.qml b/src/qml/pages/wallet/DesktopWallets.qml index 59a7ac15e4..03c4231427 100644 --- a/src/qml/pages/wallet/DesktopWallets.qml +++ b/src/qml/pages/wallet/DesktopWallets.qml @@ -129,9 +129,8 @@ Page { id: sendTab CoreText { text: "Send" } } - Item { + RequestPayment { id: receiveTab - CoreText { text: "Receive" } } Item { id: blockClockTab diff --git a/src/qml/pages/wallet/RequestConfirmation.qml b/src/qml/pages/wallet/RequestConfirmation.qml new file mode 100644 index 0000000000..aed4931ab5 --- /dev/null +++ b/src/qml/pages/wallet/RequestConfirmation.qml @@ -0,0 +1,167 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +import "../../controls" +import "../../components" +import "../settings" + +Page { + id: root + background: null + property string label: "alice" + property string message: "payment for goods" + property string amount: "0.000" + + header: NavigationBar2 { + id: navbar + leftItem: NavButton { + iconSource: "image://images/caret-left" + text: qsTr("Back") + onClicked: { + root.StackView.view.pop() + } + } + centerItem: Item { + id: header + Layout.fillWidth: true + + CoreText { + anchors.left: parent.left + text: qsTr("Payment request") + font.pixelSize: 21 + bold: true + } + } + } + + ScrollView { + clip: true + width: parent.width + height: parent.height + contentWidth: width + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width, 450) + spacing: 30 + + Image { + width: 60 + height: 60 + Layout.alignment: Qt.AlignHCenter + source: "image://images/pending" + sourceSize.width: 60 + sourceSize.height: 60 + } + + CoreText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Created just now") + color: Theme.color.neutral7 + font.pixelSize: 18 + } + + LabeledTextInput { + id: labelInput + Layout.fillWidth: true + labelText: qsTr("Label") + visible: label != "" + enabled: false + text: label + } + + Item { + BitcoinAmount { + id: bitcoinAmount + } + + height: 50 + Layout.fillWidth: true + visible: amount != "" + CoreText { + anchors.left: parent.left + anchors.top: parent.top + color: Theme.color.neutral7 + text: qsTr("Amount") + font.pixelSize: 15 + } + + TextField { + id: bitcoinAmountText + anchors.left: parent.left + anchors.bottom: parent.bottom + leftPadding: 0 + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: Theme.color.neutral7 + background: Item {} + placeholderText: "0.00000000" + text: request.amount + enabled: false + onTextChanged: { + bitcoinAmountText.text = bitcoinAmount.sanitize(bitcoinAmountText.text) + } + } + } + + + LabeledTextInput { + id: messageInput + Layout.fillWidth: true + labelText: qsTr("Message") + visible: message != "" + enabled: false + text: message + } + + Item { + height: addressLabel.height + addressText.height + Layout.fillWidth: true + CoreText { + id: addressLabel + anchors.left: parent.left + anchors.top: parent.top + color: Theme.color.neutral7 + text: qsTr("Address") + font.pixelSize: 15 + } + + CoreText { + id: addressText + anchors.left: parent.left + anchors.right: copyIcon.left + anchors.top: addressLabel.bottom + leftPadding: 0 + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral9 + text: "bc1q wvlv mha3 cvhy q6qz tjzu mq2d 63ff htzy xxu6 q8" + } + + Icon { + id: copyIcon + anchors.right: parent.right + anchors.verticalCenter: addressText.verticalCenter + source: "image://images/copy" + color: Theme.color.neutral8 + size: 30 + enabled: true + onClicked: { + Clipboard.setText(addressText.text) + } + } + } + } + } +} diff --git a/src/qml/pages/wallet/RequestPayment.qml b/src/qml/pages/wallet/RequestPayment.qml new file mode 100644 index 0000000000..0b04650f25 --- /dev/null +++ b/src/qml/pages/wallet/RequestPayment.qml @@ -0,0 +1,158 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +import "../../controls" +import "../../components" +import "../settings" + +PageStack { + id: stackView + initialItem: pageComponent + + Component { + id: pageComponent + Page { + id: root + background: null + + header: NavigationBar2 { + id: navbar + centerItem: Item { + id: header + Layout.fillWidth: true + + CoreText { + anchors.left: parent.left + text: qsTr("Request a payment") + font.pixelSize: 21 + bold: true + } + } + } + + ScrollView { + clip: true + width: parent.width + height: parent.height + contentWidth: width + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width, 450) + spacing: 30 + + CoreText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("All fields are optional.") + color: Theme.color.neutral7 + font.pixelSize: 15 + } + + LabeledTextInput { + id: label + Layout.fillWidth: true + labelText: qsTr("Label") + placeholderText: qsTr("Enter label...") + } + + Item { + BitcoinAmount { + id: bitcoinAmount + } + + height: 50 + Layout.fillWidth: true + CoreText { + anchors.left: parent.left + anchors.top: parent.top + color: Theme.color.neutral7 + text: "Amount" + font.pixelSize: 15 + } + + TextField { + id: amountInput + anchors.left: parent.left + anchors.bottom: parent.bottom + leftPadding: 0 + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: Theme.color.neutral7 + background: Item {} + placeholderText: "0.00000000" + onTextEdited: { + amountInput.text = bitcoinAmount.sanitize(amountInput.text) + } + } + Item { + width: unitLabel.width + flipIcon.width + height: Math.max(unitLabel.height, flipIcon.height) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + MouseArea { + anchors.fill: parent + onClicked: { + if (bitcoinAmount.unit == BitcoinAmount.BTC) { + amountInput.text = bitcoinAmount.convert(amountInput.text, BitcoinAmount.BTC) + bitcoinAmount.unit = BitcoinAmount.SAT + } else { + amountInput.text = bitcoinAmount.convert(amountInput.text, BitcoinAmount.SAT) + bitcoinAmount.unit = BitcoinAmount.BTC + } + } + } + CoreText { + id: unitLabel + anchors.right: flipIcon.left + anchors.verticalCenter: parent.verticalCenter + text: bitcoinAmount.unitLabel + font.pixelSize: 18 + color: Theme.color.neutral7 + } + Icon { + id: flipIcon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + source: "image://images/flip-vertical" + color: Theme.color.neutral8 + size: 30 + } + } + } + + LabeledTextInput { + id: message + Layout.fillWidth: true + labelText: qsTr("Message") + placeholderText: qsTr("Enter message...") + } + + ContinueButton { + id: continueButton + Layout.preferredWidth: Math.min(300, parent.width - 2 * Layout.leftMargin) + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.alignment: Qt.AlignCenter + text: qsTr("Continue") + onClicked: stackView.push(confirmationComponent) + } + } + } + } + } + + Component { + id: confirmationComponent + RequestConfirmation { + } + } +} diff --git a/src/qml/res/icons/copy.png b/src/qml/res/icons/copy.png new file mode 100644 index 0000000000..440acb56cb Binary files /dev/null and b/src/qml/res/icons/copy.png differ diff --git a/src/qml/res/icons/flip-vertical.png b/src/qml/res/icons/flip-vertical.png new file mode 100644 index 0000000000..fb6af29aa9 Binary files /dev/null and b/src/qml/res/icons/flip-vertical.png differ diff --git a/src/qml/res/icons/pending.png b/src/qml/res/icons/pending.png new file mode 100644 index 0000000000..ec7858d615 Binary files /dev/null and b/src/qml/res/icons/pending.png differ diff --git a/src/qml/res/src/copy.svg b/src/qml/res/src/copy.svg new file mode 100644 index 0000000000..c59824e5f6 --- /dev/null +++ b/src/qml/res/src/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/qml/res/src/flip-vertical.svg b/src/qml/res/src/flip-vertical.svg new file mode 100644 index 0000000000..5fabdcaef8 --- /dev/null +++ b/src/qml/res/src/flip-vertical.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/qml/res/src/pending.svg b/src/qml/res/src/pending.svg new file mode 100644 index 0000000000..cef63eeb79 --- /dev/null +++ b/src/qml/res/src/pending.svg @@ -0,0 +1,4 @@ + + + +