diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 306dbe2aa6..47264bb62e 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -43,8 +43,11 @@ QT_MOC_CPP = \ qml/models/moc_networktraffictower.cpp \ qml/models/moc_nodemodel.cpp \ qml/models/moc_options_model.cpp \ + qml/models/moc_paymentrequest.cpp \ qml/models/moc_peerdetailsmodel.cpp \ - qml/models/moc_peerlistsortproxy.cpp \ + qml/models/moc_peerlistsortproxy.cpp \\ + qml/models/moc_sendrecipient.cpp \ + qml/models/moc_sendrecipientslistmodel.cpp \ qml/models/moc_transaction.cpp \ qml/models/moc_sendrecipient.cpp \ qml/models/moc_walletlistmodel.cpp \ @@ -134,10 +137,12 @@ BITCOIN_QT_H = \ qml/models/networktraffictower.h \ qml/models/nodemodel.h \ qml/models/options_model.h \ + qml/models/paymentrequest.h \ qml/models/peerdetailsmodel.h \ qml/models/peerlistsortproxy.h \ qml/models/transaction.h \ qml/models/sendrecipient.h \ + qml/models/sendrecipientslistmodel.h \ qml/models/walletlistmodel.h \ qml/models/walletqmlmodel.h \ qml/models/walletqmlmodeltransaction.h \ @@ -335,10 +340,12 @@ BITCOIN_QML_BASE_CPP = \ qml/models/networktraffictower.cpp \ qml/models/nodemodel.cpp \ qml/models/options_model.cpp \ + qml/models/paymentrequest.cpp \ qml/models/peerdetailsmodel.cpp \ qml/models/peerlistsortproxy.cpp \ qml/models/transaction.cpp \ qml/models/sendrecipient.cpp \ + qml/models/sendrecipientslistmodel.cpp \ qml/models/walletlistmodel.cpp \ qml/models/walletqmlmodel.cpp \ qml/models/walletqmlmodeltransaction.cpp \ @@ -353,6 +360,7 @@ QML_RES_FONTS = \ QML_RES_ICONS = \ qml/res/icons/add-wallet-dark.png \ + qml/res/icons/alert-filled.png \ qml/res/icons/arrow-down.png \ qml/res/icons/arrow-up.png \ qml/res/icons/bitcoin-circle.png \ @@ -379,6 +387,7 @@ QML_RES_ICONS = \ qml/res/icons/network-dark.png \ qml/res/icons/network-light.png \ qml/res/icons/plus.png \ + qml/res/icons/plus-big-filled.png \ qml/res/icons/pending.png \ qml/res/icons/shutdown.png \ qml/res/icons/singlesig-wallet.png \ @@ -398,6 +407,7 @@ QML_RES_QML = \ qml/components/BlockClock.qml \ qml/components/BlockClockDisplayMode.qml \ qml/components/BlockCounter.qml \ + qml/components/BitcoinAmountInputField.qml \ qml/components/ConnectionOptions.qml \ qml/components/ConnectionSettings.qml \ qml/components/DeveloperOptions.qml \ @@ -420,12 +430,12 @@ QML_RES_QML = \ qml/controls/CoreCheckBox.qml \ qml/controls/CoreText.qml \ qml/controls/CoreTextField.qml \ - qml/controls/EllipsisMenuButton.qml \ qml/controls/EllipsisMenuToggleItem.qml \ qml/controls/ExternalLink.qml \ qml/controls/FocusBorder.qml \ qml/controls/Header.qml \ qml/controls/Icon.qml \ + qml/controls/IconButton.qml \ qml/controls/InformationPage.qml \ qml/controls/IPAddressValueInput.qml \ qml/controls/KeyValueRow.qml \ @@ -484,6 +494,7 @@ QML_RES_QML = \ qml/pages/wallet/CreatePassword.qml \ qml/pages/wallet/CreateWalletWizard.qml \ qml/pages/wallet/DesktopWallets.qml \ + qml/pages/wallet/MultipleSendReview.qml \ qml/pages/wallet/RequestPayment.qml \ qml/pages/wallet/Send.qml \ qml/pages/wallet/SendResult.qml \ diff --git a/src/qml/bitcoin.cpp b/src/qml/bitcoin.cpp index f3f78fac71..b74ab001a3 100644 --- a/src/qml/bitcoin.cpp +++ b/src/qml/bitcoin.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -340,6 +341,7 @@ int QmlGuiMain(int argc, char* argv[]) qmlRegisterType("org.bitcoincore.qt", 1, 0, "LineGraph"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "PeerDetailsModel", ""); qmlRegisterType("org.bitcoincore.qt", 1, 0, "BitcoinAmount"); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "PaymentRequest"); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "Transaction", ""); qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "SendRecipient", ""); diff --git a/src/qml/bitcoin_qml.qrc b/src/qml/bitcoin_qml.qrc index 8557ebe0e6..88d29bc1c2 100644 --- a/src/qml/bitcoin_qml.qrc +++ b/src/qml/bitcoin_qml.qrc @@ -4,6 +4,7 @@ components/BlockClock.qml components/BlockClockDisplayMode.qml components/BlockCounter.qml + components/BitcoinAmountInputField.qml components/ConnectionOptions.qml components/ConnectionSettings.qml components/DeveloperOptions.qml @@ -30,12 +31,12 @@ controls/FocusBorder.qml controls/Header.qml controls/Icon.qml + controls/IconButton.qml controls/InformationPage.qml controls/IPAddressValueInput.qml controls/KeyValueRow.qml controls/LabeledTextInput.qml controls/LabeledCoinControlButton.qml - controls/EllipsisMenuButton.qml controls/EllipsisMenuToggleItem.qml controls/NavButton.qml controls/NavigationBar.qml @@ -90,6 +91,7 @@ pages/wallet/CreatePassword.qml pages/wallet/CreateWalletWizard.qml pages/wallet/DesktopWallets.qml + pages/wallet/MultipleSendReview.qml pages/wallet/RequestPayment.qml pages/wallet/Send.qml pages/wallet/SendResult.qml @@ -99,6 +101,7 @@ res/icons/add-wallet-dark.png + res/icons/alert-filled.png res/icons/arrow-down.png res/icons/arrow-up.png res/icons/bitcoin-circle.png @@ -126,6 +129,7 @@ res/icons/network-dark.png res/icons/network-light.png res/icons/plus.png + res/icons/plus-big-filled.png res/icons/pending.png res/icons/shutdown.png res/icons/singlesig-wallet.png diff --git a/src/qml/bitcoinamount.cpp b/src/qml/bitcoinamount.cpp index 153d8fabae..eca63bfbf6 100644 --- a/src/qml/bitcoinamount.cpp +++ b/src/qml/bitcoinamount.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2025 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -7,19 +7,9 @@ #include #include - -BitcoinAmount::BitcoinAmount(QObject *parent) : QObject(parent) +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) @@ -43,6 +33,30 @@ QString BitcoinAmount::sanitize(const QString &text) return result; } +qint64 BitcoinAmount::satoshi() const +{ + return m_satoshi; +} + +void BitcoinAmount::setSatoshi(qint64 new_amount) +{ + m_isSet = true; + if (m_satoshi != new_amount) { + m_satoshi = new_amount; + Q_EMIT amountChanged(); + } +} + +void BitcoinAmount::clear() +{ + if (!m_isSet && m_satoshi == 0) { + return; + } + m_satoshi = 0; + m_isSet = false; + Q_EMIT amountChanged(); +} + BitcoinAmount::Unit BitcoinAmount::unit() const { return m_unit; @@ -58,97 +72,82 @@ QString BitcoinAmount::unitLabel() const { switch (m_unit) { case Unit::BTC: return "₿"; - case Unit::SAT: return "Sat"; + case Unit::SAT: return "sat"; } assert(false); } -QString BitcoinAmount::amount() const +void BitcoinAmount::flipUnit() { - return m_amount; + if (m_unit == Unit::BTC) { + m_unit = Unit::SAT; + } else { + m_unit = Unit::BTC; + } + Q_EMIT unitChanged(); + Q_EMIT amountChanged(); } -QString BitcoinAmount::satoshiAmount() const +QString BitcoinAmount::satsToBtcString(qint64 sat) { - return toSatoshis(m_amount); -} + const bool negative = sat < 0; + qint64 absSat = negative ? -sat : sat; -void BitcoinAmount::setAmount(const QString& new_amount) -{ - m_amount = sanitize(new_amount); - Q_EMIT amountChanged(); + const qint64 wholePart = absSat / COIN; + const qint64 fracInt = absSat % COIN; + QString fracPart = QString("%1").arg(fracInt, 8, 10, QLatin1Char('0')); + + QString result = QString::number(wholePart) + '.' + fracPart; + if (negative) { + result.prepend('-'); + } + return result; } -QString BitcoinAmount::toSatoshis(const QString& text) const +QString BitcoinAmount::toDisplay() const { + if (!m_isSet) { + return ""; + } if (m_unit == Unit::SAT) { - return text; + return QString::number(m_satoshi); } else { - return convert(text, m_unit); + return satsToBtcString(m_satoshi); } } -long long BitcoinAmount::toSatoshis(QString& amount, const Unit unit) +qint64 BitcoinAmount::btcToSats(const QString& btcSanitized) { - int num_decimals = decimals(unit); - - QStringList parts = amount.remove(' ').split("."); + if (btcSanitized.isEmpty() || btcSanitized == ".") return 0; - QString whole = parts[0]; - QString decimals; + QString cleaned = btcSanitized; + if (cleaned.startsWith('.')) cleaned.prepend('0'); - if(parts.size() > 1) - { - decimals = parts[1]; + QStringList parts = cleaned.split('.'); + const qint64 whole = parts[0].isEmpty() ? 0 : parts[0].toLongLong(); + qint64 frac = 0; + if (parts.size() == 2) { + frac = parts[1].leftJustified(8, '0').toLongLong(); } - QString str = whole + decimals.leftJustified(num_decimals, '0', true); - return str.toLongLong(); + return whole * COIN + frac; } -QString BitcoinAmount::convert(const QString& amount, Unit unit) const +void BitcoinAmount::fromDisplay(const QString& text) { - if (amount == "") { - return amount; - } - - QString result = amount; - int decimalPosition = result.indexOf("."); - - if (decimalPosition == -1) { - decimalPosition = result.length(); - result.append("."); + if (text.trimmed().isEmpty()) { + clear(); + return; } - if (unit == Unit::BTC) { - int numDigitsAfterDecimal = result.length() - decimalPosition - 1; - if (numDigitsAfterDecimal < 8) { - result.append(QString(8 - numDigitsAfterDecimal, '0')); - } - result.remove(decimalPosition, 1); - - while (result.startsWith('0') && result.length() > 1) { - result.remove(0, 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, "."); - - while (result.endsWith('0') && result.contains('.')) { - result.chop(1); - } - if (result.endsWith('.')) { - result.chop(1); - } - if (result.startsWith('.')) { - result.insert(0, "0"); - } + qint64 newSat = 0; + if (m_unit == Unit::BTC) { + QString sanitized = sanitize(text); + newSat = btcToSats(sanitized); + } else { + QString digitsOnly = text; + digitsOnly.remove(QRegExp("[^0-9]")); + newSat = digitsOnly.trimmed().isEmpty() ? 0 : digitsOnly.toLongLong(); } - - return result; + setSatoshi(newSat); } diff --git a/src/qml/bitcoinamount.h b/src/qml/bitcoinamount.h index 0631a05b87..df92d498ee 100644 --- a/src/qml/bitcoinamount.h +++ b/src/qml/bitcoinamount.h @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2025 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -15,8 +15,8 @@ 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) - Q_PROPERTY(QString satoshiAmount READ satoshiAmount NOTIFY amountChanged) + Q_PROPERTY(QString display READ toDisplay WRITE fromDisplay NOTIFY amountChanged) + Q_PROPERTY(qint64 satoshi READ satoshi WRITE setSatoshi NOTIFY amountChanged) public: enum class Unit { @@ -30,27 +30,31 @@ class BitcoinAmount : public QObject Unit unit() const; void setUnit(Unit unit); QString unitLabel() const; - QString amount() const; - void setAmount(const QString& new_amount); - QString satoshiAmount() const; + + QString toDisplay() const; + void fromDisplay(const QString& new_amount); + qint64 satoshi() const; + void setSatoshi(qint64 new_amount); + + bool isSet() const { return m_isSet; } + + static QString satsToBtcString(qint64 sat); public Q_SLOTS: - QString sanitize(const QString& text); - QString convert(const QString& text, Unit unit) const; - QString toSatoshis(const QString& text) const; + void flipUnit(); + void clear(); Q_SIGNALS: void unitChanged(); - void unitLabelChanged(); void amountChanged(); private: - long long toSatoshis(QString &amount, const Unit unit); - int decimals(Unit unit); + QString sanitize(const QString& text); + static qint64 btcToSats(const QString& btc); - Unit m_unit; - QString m_unitLabel; - QString m_amount; + qint64 m_satoshi{0}; + bool m_isSet{false}; + Unit m_unit{Unit::BTC}; }; #endif // BITCOIN_QML_BITCOINAMOUNT_H diff --git a/src/qml/components/BitcoinAmountInputField.qml b/src/qml/components/BitcoinAmountInputField.qml new file mode 100644 index 0000000000..b0130ca2f7 --- /dev/null +++ b/src/qml/components/BitcoinAmountInputField.qml @@ -0,0 +1,122 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +import "../controls" + +ColumnLayout { + id: root + + property var amount + property string errorText: "" + property string labelText: qsTr("Amount") + property bool enabled: true + + signal editingFinished(string value) + + Layout.fillWidth: true + spacing: 4 + + Item { + id: inputRow + height: amountInput.height + Layout.fillWidth: true + + CoreText { + id: lbl + width: 110 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignLeft + text: root.labelText + font.pixelSize: 18 + } + + TextField { + id: amountInput + anchors.left: lbl.right + anchors.verticalCenter: parent.verticalCenter + leftPadding: 0 + enabled: root.enabled + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + background: Item {} + placeholderText: "0.00000000" + selectByMouse: true + + text: root.amount ? root.amount.display : "" + + onEditingFinished: { + if (root.amount) { + root.amount.display = text + } + root.editingFinished(text) + } + + onActiveFocusChanged: { + if (!activeFocus && root.amount) { + root.amount.display = text + root.editingFinished(text) + } + } + } + + Item { + width: unitLabel.width + flipIcon.width + height: Math.max(unitLabel.height, flipIcon.height) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + opacity: root.enabled ? 1.0 : 0.5 + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: root.enabled && root.amount + onClicked: root.amount.flipUnit() + } + + CoreText { + id: unitLabel + anchors.right: flipIcon.left + anchors.verticalCenter: parent.verticalCenter + text: root.amount ? root.amount.unitLabel : "" + font.pixelSize: 18 + color: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + } + + Icon { + id: flipIcon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + source: "image://images/flip-vertical" + icon.color: enabled ? Theme.color.neutral8 : Theme.color.neutral4 + size: 30 + } + } + } + + RowLayout { + id: errorRow + Layout.fillWidth: true + visible: root.errorText.length > 0 + + Icon { + source: "image://images/alert-filled" + size: 22 + color: Theme.color.red + } + + CoreText { + text: root.errorText + font.pixelSize: 15 + color: Theme.color.red + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + } + } +} diff --git a/src/qml/controls/CoreText.qml b/src/qml/controls/CoreText.qml index 043e9a1fde..4a5f44aaf3 100644 --- a/src/qml/controls/CoreText.qml +++ b/src/qml/controls/CoreText.qml @@ -5,15 +5,20 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 -Text { +Label { + id: label property bool bold: false property bool wrap: true + color: enabled ? Theme.color.neutral9 : Theme.color.neutral4 + font.family: "Inter" font.styleName: bold ? "Semi Bold" : "Regular" font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + wrapMode: wrap ? Text.WordWrap : Text.NoWrap Behavior on color { diff --git a/src/qml/controls/EllipsisMenuButton.qml b/src/qml/controls/IconButton.qml similarity index 51% rename from src/qml/controls/EllipsisMenuButton.qml rename to src/qml/controls/IconButton.qml index 593ede0902..0cddb36cdf 100644 --- a/src/qml/controls/EllipsisMenuButton.qml +++ b/src/qml/controls/IconButton.qml @@ -11,12 +11,16 @@ import org.bitcoincore.qt 1.0 Button { id: root + property color iconColor: Theme.color.orange property color hoverColor: Theme.color.orange property color activeColor: Theme.color.orange + property int size: 35 + property alias iconSource: icon.source hoverEnabled: AppMode.isDesktop - implicitHeight: 35 - implicitWidth: 35 + height: root.size + width: root.size + padding: 0 MouseArea { anchors.fill: parent @@ -25,28 +29,44 @@ Button { cursorShape: Qt.PointingHandCursor } - background: null + background: Rectangle { + id: bg + anchors.fill: parent + radius: 5 + color: Theme.color.background + + + Behavior on color { + ColorAnimation { duration: 150 } + } + } contentItem: Icon { - id: ellipsisIcon + id: icon anchors.fill: parent source: "image://images/ellipsis" - color: Theme.color.neutral9 - size: 35 + size: root.size + color: iconColor + + Behavior on color { + ColorAnimation { duration: 150 } + } } states: [ State { name: "CHECKED"; when: root.checked - PropertyChanges { target: ellipsisIcon; color: activeColor } + PropertyChanges { target: icon; color: activeColor } }, State { name: "HOVER"; when: root.hovered - PropertyChanges { target: ellipsisIcon; color: hoverColor } + PropertyChanges { target: icon; color: hoverColor } + PropertyChanges { target: bg; color: Theme.color.neutral2 } }, State { name: "DISABLED"; when: !root.enabled - PropertyChanges { target: ellipsisIcon; color: Theme.color.neutral4 } + PropertyChanges { target: icon; color: Theme.color.neutral2 } + PropertyChanges { target: bg; color: Theme.color.background } } ] } diff --git a/src/qml/controls/NavButton.qml b/src/qml/controls/NavButton.qml index 965161b983..37e4114a03 100644 --- a/src/qml/controls/NavButton.qml +++ b/src/qml/controls/NavButton.qml @@ -53,6 +53,7 @@ AbstractButton { } contentItem: RowLayout { spacing: 0 + anchors.fill: parent Loader { id: button_background active: root.iconSource.toString().length > 0 diff --git a/src/qml/controls/SendOptionsPopup.qml b/src/qml/controls/SendOptionsPopup.qml index f67ff139ec..b96ffc5ec8 100644 --- a/src/qml/controls/SendOptionsPopup.qml +++ b/src/qml/controls/SendOptionsPopup.qml @@ -13,14 +13,36 @@ OptionPopup { id: root property alias coinControlEnabled: coinControlToggle.checked + property alias multipleRecipientsEnabled: multipleRecipientsToggle.checked + + implicitWidth: 300 + implicitHeight: 100 clip: true modal: true dim: false - EllipsisMenuToggleItem { - id: coinControlToggle + ColumnLayout { + id: columnLayout anchors.centerIn: parent - text: qsTr("Enable Coin control") + anchors.margins: 10 + spacing: 0 + + EllipsisMenuToggleItem { + id: coinControlToggle + Layout.fillWidth: true + text: qsTr("Enable Coin control") + } + + Separator { + id: separator + Layout.fillWidth: true + } + + EllipsisMenuToggleItem { + id: multipleRecipientsToggle + Layout.fillWidth: true + text: qsTr("Multiple Recipients") + } } -} \ No newline at end of file +} diff --git a/src/qml/models/coinslistmodel.cpp b/src/qml/models/coinslistmodel.cpp index 76142e74f3..a34449111f 100644 --- a/src/qml/models/coinslistmodel.cpp +++ b/src/qml/models/coinslistmodel.cpp @@ -122,14 +122,14 @@ QString CoinsListModel::totalSelected() const QString CoinsListModel::changeAmount() const { - CAmount change = m_total_amount - m_wallet_model->sendRecipient()->cAmount(); + CAmount change = m_total_amount - m_wallet_model->sendRecipientList()->totalAmountSatoshi(); change = std::abs(change); return BitcoinUnits::format(BitcoinUnits::Unit::BTC, change); } bool CoinsListModel::overRequiredAmount() const { - return m_total_amount > m_wallet_model->sendRecipient()->cAmount(); + return m_total_amount > m_wallet_model->sendRecipientList()->totalAmountSatoshi(); } int CoinsListModel::coinCount() const diff --git a/src/qml/models/paymentrequest.cpp b/src/qml/models/paymentrequest.cpp new file mode 100644 index 0000000000..a73479546a --- /dev/null +++ b/src/qml/models/paymentrequest.cpp @@ -0,0 +1,107 @@ +// Copyright (c) 2024-2025 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 +#include + +PaymentRequest::PaymentRequest(QObject* parent) + : QObject(parent) +{ + m_amount = new BitcoinAmount(this); + m_label = ""; + m_message = ""; + m_id = ""; +} + +QString PaymentRequest::address() const +{ + return QString::fromStdString(EncodeDestination(m_destination)); +} + +QString PaymentRequest::label() const +{ + return m_label; +} + +void PaymentRequest::setLabel(const QString& label) +{ + if (m_label == label) + return; + + m_label = label; + Q_EMIT labelChanged(); +} + +QString PaymentRequest::message() const +{ + return m_message; +} + +void PaymentRequest::setMessage(const QString& message) +{ + if (m_message == message) + return; + + m_message = message; + Q_EMIT messageChanged(); +} + +BitcoinAmount* PaymentRequest::amount() const +{ + return m_amount; +} + +QString PaymentRequest::id() const +{ + return m_id; +} + +void PaymentRequest::setId(const unsigned int id) +{ + m_id = QString::number(id); + Q_EMIT idChanged(); +} + +void PaymentRequest::setDestination(const CTxDestination& destination) +{ + m_destination = destination; + Q_EMIT addressChanged(); +} + +CTxDestination PaymentRequest::destination() const +{ + return m_destination; +} + +void PaymentRequest::setAmountError(const QString& error) +{ + if (m_amountError == error) + return; + + m_amountError = error; + Q_EMIT amountErrorChanged(); +} + +QString PaymentRequest::amountError() const +{ + return m_amountError; +} + +void PaymentRequest::clear() +{ + m_destination = CNoDestination(); + m_label.clear(); + m_message.clear(); + m_amount->clear(); + m_amountError.clear(); + m_id.clear(); + Q_EMIT addressChanged(); + Q_EMIT labelChanged(); + Q_EMIT messageChanged(); + Q_EMIT amountErrorChanged(); + Q_EMIT idChanged(); +} diff --git a/src/qml/models/paymentrequest.h b/src/qml/models/paymentrequest.h new file mode 100644 index 0000000000..aa7e26cd15 --- /dev/null +++ b/src/qml/models/paymentrequest.h @@ -0,0 +1,63 @@ +// Copyright (c) 2024-2025 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_MODELS_PAYMENTREQUEST_H +#define BITCOIN_QML_MODELS_PAYMENTREQUEST_H + +#include + +#include + +#include + +class PaymentRequest : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString address READ address NOTIFY addressChanged) + Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged) + Q_PROPERTY(QString message READ message WRITE setMessage NOTIFY messageChanged) + Q_PROPERTY(BitcoinAmount* amount READ amount CONSTANT) + Q_PROPERTY(QString amountError READ amountError NOTIFY amountErrorChanged) + Q_PROPERTY(QString id READ id NOTIFY idChanged) + +public: + explicit PaymentRequest(QObject* parent = nullptr); + + QString address() const; + + QString label() const; + void setLabel(const QString& label); + + QString message() const; + void setMessage(const QString& message); + + BitcoinAmount* amount() const; + QString amountError() const; + void setAmountError(const QString& error); + + QString id() const; + void setId(unsigned int id); + + void setDestination(const CTxDestination& destination); + CTxDestination destination() const; + + Q_INVOKABLE void clear(); + +Q_SIGNALS: + void addressChanged(); + void labelChanged(); + void messageChanged(); + void amountErrorChanged(); + void idChanged(); + +private: + CTxDestination m_destination; + QString m_label; + QString m_message; + QString m_amountError; + BitcoinAmount* m_amount; + QString m_id; +}; + +#endif // BITCOIN_QML_MODELS_PAYMENTREQUEST_H diff --git a/src/qml/models/sendrecipient.cpp b/src/qml/models/sendrecipient.cpp index 138bea6559..f9b028c59f 100644 --- a/src/qml/models/sendrecipient.cpp +++ b/src/qml/models/sendrecipient.cpp @@ -3,11 +3,16 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include -#include -SendRecipient::SendRecipient(QObject* parent) - : QObject(parent), m_address(""), m_label(""), m_amount(""), m_message("") +#include +#include + +#include + +SendRecipient::SendRecipient(WalletQmlModel* wallet, QObject* parent) + : QObject(parent), m_wallet(wallet), m_amount(new BitcoinAmount(this)) { + connect(m_amount, &BitcoinAmount::amountChanged, this, &SendRecipient::validateAmount); } QString SendRecipient::address() const @@ -20,6 +25,20 @@ void SendRecipient::setAddress(const QString& address) if (m_address != address) { m_address = address; Q_EMIT addressChanged(); + validateAddress(); + } +} + +QString SendRecipient::addressError() const +{ + return m_addressError; +} + +void SendRecipient::setAddressError(const QString& error) +{ + if (m_addressError != error) { + m_addressError = error; + Q_EMIT addressErrorChanged(); } } @@ -36,16 +55,21 @@ void SendRecipient::setLabel(const QString& label) } } -QString SendRecipient::amount() const +BitcoinAmount* SendRecipient::amount() const { return m_amount; } -void SendRecipient::setAmount(const QString& amount) +QString SendRecipient::amountError() const { - if (m_amount != amount) { - m_amount = amount; - Q_EMIT amountChanged(); + return m_amountError; +} + +void SendRecipient::setAmountError(const QString& error) +{ + if (m_amountError != error) { + m_amountError = error; + Q_EMIT amountErrorChanged(); } } @@ -69,22 +93,51 @@ bool SendRecipient::subtractFeeFromAmount() const CAmount SendRecipient::cAmount() const { - // TODO: Figure out who owns the parsing of SendRecipient::amount to CAmount - if (m_amount == "") { - return 0; - } - return m_amount.toLongLong(); + return m_amount->satoshi(); } void SendRecipient::clear() { m_address = ""; m_label = ""; - m_amount = ""; + m_amount->setSatoshi(0); m_message = ""; m_subtractFeeFromAmount = false; Q_EMIT addressChanged(); Q_EMIT labelChanged(); - Q_EMIT amountChanged(); Q_EMIT messageChanged(); + Q_EMIT amount()->amountChanged(); +} + +void SendRecipient::validateAddress() +{ + setAddressError(""); + + if (!m_address.isEmpty() && !IsValidDestinationString(m_address.toStdString())) { + setAddressError(tr("Invalid address")); + } + + Q_EMIT isValidChanged(); +} + +void SendRecipient::validateAmount() +{ + setAmountError(""); + + if (m_amount->isSet()) { + if (m_amount->satoshi() <= 0) { + setAmountError(tr("Amount must be greater than zero")); + } else if (m_amount->satoshi() > MAX_MONEY) { + setAmountError(tr("Amount exceeds maximum limit")); + } else if (m_amount->satoshi() > m_wallet->balanceSatoshi()) { + setAmountError(tr("Amount exceeds available balance")); + } + } + + Q_EMIT isValidChanged(); +} + +bool SendRecipient::isValid() const +{ + return m_addressError.isEmpty() && m_amountError.isEmpty() && m_amount->satoshi() > 0 && !m_address.isEmpty(); } diff --git a/src/qml/models/sendrecipient.h b/src/qml/models/sendrecipient.h index 042e97c9de..5338a0b2cb 100644 --- a/src/qml/models/sendrecipient.h +++ b/src/qml/models/sendrecipient.h @@ -5,29 +5,39 @@ #ifndef BITCOIN_QML_MODELS_SENDRECIPIENT_H #define BITCOIN_QML_MODELS_SENDRECIPIENT_H +#include + #include #include -#include + +class WalletQmlModel; class SendRecipient : public QObject { Q_OBJECT Q_PROPERTY(QString address READ address WRITE setAddress NOTIFY addressChanged) Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged) - Q_PROPERTY(QString amount READ amount WRITE setAmount NOTIFY amountChanged) Q_PROPERTY(QString message READ message WRITE setMessage NOTIFY messageChanged) + Q_PROPERTY(BitcoinAmount* amount READ amount CONSTANT) + + Q_PROPERTY(QString addressError READ addressError NOTIFY addressErrorChanged) + Q_PROPERTY(QString amountError READ amountError NOTIFY amountErrorChanged) + Q_PROPERTY(bool isValid READ isValid NOTIFY isValidChanged) public: - explicit SendRecipient(QObject* parent = nullptr); + explicit SendRecipient(WalletQmlModel* wallet, QObject* parent = nullptr); QString address() const; void setAddress(const QString& address); + QString addressError() const; + void setAddressError(const QString& error); QString label() const; void setLabel(const QString& label); - QString amount() const; - void setAmount(const QString& amount); + BitcoinAmount* amount() const; + QString amountError() const; + void setAmountError(const QString& error); QString message() const; void setMessage(const QString& message); @@ -36,19 +46,29 @@ class SendRecipient : public QObject bool subtractFeeFromAmount() const; + bool isValid() const; + Q_INVOKABLE void clear(); Q_SIGNALS: void addressChanged(); + void addressErrorChanged(); + void amountErrorChanged(); void labelChanged(); - void amountChanged(); void messageChanged(); + void isValidChanged(); private: - QString m_address; - QString m_label; - QString m_amount; - QString m_message; + void validateAddress(); + void validateAmount(); + + WalletQmlModel* m_wallet; + QString m_address{""}; + QString m_addressError{""}; + QString m_label{""}; + QString m_message{""}; + BitcoinAmount* m_amount; + QString m_amountError{""}; bool m_subtractFeeFromAmount{false}; }; diff --git a/src/qml/models/sendrecipientslistmodel.cpp b/src/qml/models/sendrecipientslistmodel.cpp new file mode 100644 index 0000000000..d32aa4c82c --- /dev/null +++ b/src/qml/models/sendrecipientslistmodel.cpp @@ -0,0 +1,144 @@ +// Copyright (c) 2025 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 + +SendRecipientsListModel::SendRecipientsListModel(QObject* parent) + : QAbstractListModel(parent) +{ + m_wallet = qobject_cast(parent); + auto* recipient = new SendRecipient(m_wallet, this); + connect(recipient->amount(), &BitcoinAmount::amountChanged, + this, &SendRecipientsListModel::updateTotalAmount); + m_recipients.append(recipient); +} + +int SendRecipientsListModel::rowCount(const QModelIndex&) const +{ + return m_recipients.size(); +} + +QVariant SendRecipientsListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() >= m_recipients.size()) + return {}; + + const auto& r = m_recipients[index.row()]; + switch (role) { + case AddressRole: return r->address(); + case LabelRole: return r->label(); + case AmountRole: return r->amount()->toDisplay(); + case MessageRole: return r->message(); + default: return {}; + } + return {}; +} + +QHash SendRecipientsListModel::roleNames() const +{ + return { + {AddressRole, "address"}, + {LabelRole, "label"}, + {AmountRole, "amount"}, + {MessageRole, "message"}, + }; +} + +void SendRecipientsListModel::add() +{ + const int row = m_recipients.size(); + beginInsertRows(QModelIndex(), row, row); + auto* recipient = new SendRecipient(m_wallet, this); + connect(recipient->amount(), &BitcoinAmount::amountChanged, + this, &SendRecipientsListModel::updateTotalAmount); + m_recipients.append(recipient); + endInsertRows(); + Q_EMIT countChanged(); + setCurrentIndex(row); +} + +void SendRecipientsListModel::setCurrentIndex(int row) +{ + if (row < 0 || row >= m_recipients.size()) + return; + + if (row == m_current) + return; + + m_current = row; + + Q_EMIT currentIndexChanged(); + Q_EMIT currentRecipientChanged(); +} + +void SendRecipientsListModel::next() +{ + setCurrentIndex(m_current + 1); +} + +void SendRecipientsListModel::prev() +{ + setCurrentIndex(m_current - 1); +} + +void SendRecipientsListModel::remove() +{ + if (m_recipients.size() == 1) { + return; + } + beginRemoveRows(QModelIndex(), m_current, m_current); + delete m_recipients.takeAt(m_current); + endRemoveRows(); + Q_EMIT countChanged(); + + setCurrentIndex(m_current - 1); +} + +SendRecipient* SendRecipientsListModel::currentRecipient() const +{ + if (m_current < 0 || m_current >= m_recipients.size()) + return nullptr; + + return m_recipients[m_current]; +} + +void SendRecipientsListModel::updateTotalAmount() +{ + qint64 total = 0; + for (const auto& recipient : m_recipients) { + total += recipient->amount()->satoshi(); + } + m_totalAmount = total; + Q_EMIT totalAmountChanged(); +} + +QString SendRecipientsListModel::totalAmount() const +{ + return BitcoinAmount::satsToBtcString(m_totalAmount); +} + +void SendRecipientsListModel::clear() +{ + beginResetModel(); + for (auto* recipient : m_recipients) { + delete recipient; + } + m_recipients.clear(); + m_current = 0; + m_totalAmount = 0; + + auto* recipient = new SendRecipient(m_wallet, this); + connect(recipient->amount(), &BitcoinAmount::amountChanged, + this, &SendRecipientsListModel::updateTotalAmount); + m_recipients.append(recipient); + endResetModel(); + + Q_EMIT countChanged(); + Q_EMIT totalAmountChanged(); + Q_EMIT currentRecipientChanged(); + Q_EMIT currentIndexChanged(); +} diff --git a/src/qml/models/sendrecipientslistmodel.h b/src/qml/models/sendrecipientslistmodel.h new file mode 100644 index 0000000000..2e9f2f7643 --- /dev/null +++ b/src/qml/models/sendrecipientslistmodel.h @@ -0,0 +1,64 @@ +// Copyright (c) 2025 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_MODELS_SENDRECIPIENTSLISTMODEL_H +#define BITCOIN_QML_MODELS_SENDRECIPIENTSLISTMODEL_H + +#include + +class SendRecipient; +class WalletQmlModel; + +class SendRecipientsListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int currentIndex READ currentIndex NOTIFY currentIndexChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(SendRecipient* current READ currentRecipient NOTIFY currentRecipientChanged) + Q_PROPERTY(QString totalAmount READ totalAmount NOTIFY totalAmountChanged) + +public: + enum Roles { + AddressRole = Qt::UserRole + 1, + LabelRole, + AmountRole, + MessageRole + }; + + explicit SendRecipientsListModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + Q_INVOKABLE void add(); + Q_INVOKABLE void next(); + Q_INVOKABLE void prev(); + Q_INVOKABLE void remove(); + Q_INVOKABLE void clear(); + + int currentIndex() const { return m_current + 1; } + void setCurrentIndex(int row); + SendRecipient* currentRecipient() const; + int count() const { return m_recipients.size(); } + QList recipients() const { return m_recipients; } + QString totalAmount() const; + qint64 totalAmountSatoshi() const { return m_totalAmount; } + +Q_SIGNALS: + void currentIndexChanged(); + void currentRecipientChanged(); + void countChanged(); + void totalAmountChanged(); + +private: + void updateTotalAmount(); + + WalletQmlModel* m_wallet; + QList m_recipients; + int m_current{0}; + qint64 m_totalAmount{0}; +}; + +#endif // BITCOIN_QML_MODELS_SENDRECIPIENTSLISTMODEL_H diff --git a/src/qml/models/walletqmlmodel.cpp b/src/qml/models/walletqmlmodel.cpp index cdce215608..ad5e33f227 100644 --- a/src/qml/models/walletqmlmodel.cpp +++ b/src/qml/models/walletqmlmodel.cpp @@ -1,11 +1,14 @@ -// Copyright (c) 2024 The Bitcoin Core developers + +// Copyright (c) 2024-2025 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 #include +#include #include #include @@ -18,13 +21,16 @@ #include +unsigned int WalletQmlModel::m_next_payment_request_id{1}; + WalletQmlModel::WalletQmlModel(std::unique_ptr wallet, QObject *parent) : QObject(parent) { m_wallet = std::move(wallet); m_activity_list_model = new ActivityListModel(this); m_coins_list_model = new CoinsListModel(this); - m_current_recipient = new SendRecipient(this); + m_send_recipients = new SendRecipientsListModel(this); + m_current_payment_request = new PaymentRequest(this); } WalletQmlModel::WalletQmlModel(QObject* parent) @@ -32,14 +38,16 @@ WalletQmlModel::WalletQmlModel(QObject* parent) { m_activity_list_model = new ActivityListModel(this); m_coins_list_model = new CoinsListModel(this); - m_current_recipient = new SendRecipient(this); + m_send_recipients = new SendRecipientsListModel(this); + m_current_payment_request = new PaymentRequest(this); } WalletQmlModel::~WalletQmlModel() { delete m_activity_list_model; delete m_coins_list_model; - delete m_current_recipient; + delete m_send_recipients; + delete m_current_payment_request; if (m_current_transaction) { delete m_current_transaction; } @@ -53,6 +61,14 @@ QString WalletQmlModel::balance() const return BitcoinUnits::format(BitcoinUnits::Unit::BTC, m_wallet->getBalance()); } +CAmount WalletQmlModel::balanceSatoshi() const +{ + if (!m_wallet) { + return 0; + } + return m_wallet->getBalance(); +} + QString WalletQmlModel::name() const { if (!m_wallet) { @@ -61,6 +77,29 @@ QString WalletQmlModel::name() const return QString::fromStdString(m_wallet->getWalletName()); } +void WalletQmlModel::commitPaymentRequest() +{ + if (!m_current_payment_request) { + return; + } + + if (m_current_payment_request->id().isEmpty()) { + m_current_payment_request->setId(m_next_payment_request_id++); + } + + if (m_current_payment_request->address().isEmpty()) { + // TODO: handle issues with getting the new address (wallet unlock?) + auto destination = m_wallet->getNewDestination(OutputType::BECH32M, + m_current_payment_request->label().toStdString()) + .value(); + std::string address = EncodeDestination(destination); + m_current_payment_request->setDestination(destination); + } + + m_wallet->setAddressReceiveRequest( + m_current_payment_request->destination(), m_current_payment_request->id().toStdString(), m_current_payment_request->message().toStdString()); +} + std::set WalletQmlModel::getWalletTxs() const { if (!m_wallet) { @@ -98,20 +137,25 @@ std::unique_ptr WalletQmlModel::handleTransactionChanged(Tr bool WalletQmlModel::prepareTransaction() { - if (!m_wallet || !m_current_recipient) { + if (!m_wallet || !m_send_recipients || m_send_recipients->recipients().empty()) { return false; } - CScript scriptPubKey = GetScriptForDestination(DecodeDestination(m_current_recipient->address().toStdString())); - wallet::CRecipient recipient = {scriptPubKey, m_current_recipient->cAmount(), m_current_recipient->subtractFeeFromAmount()}; - m_coin_control.m_feerate = CFeeRate(1000); + std::vector vecSend; + CAmount total = 0; + for (auto* recipient : m_send_recipients->recipients()) { + CScript scriptPubKey = GetScriptForDestination(DecodeDestination(recipient->address().toStdString())); + wallet::CRecipient c_recipient = {scriptPubKey, recipient->cAmount(), recipient->subtractFeeFromAmount()}; + m_coin_control.m_feerate = CFeeRate(1000); + vecSend.push_back(c_recipient); + total += recipient->cAmount(); + } CAmount balance = m_wallet->getBalance(); - if (balance < recipient.nAmount) { + if (balance < total) { return false; } - std::vector vecSend{recipient}; int nChangePosRet = -1; CAmount nFeeRequired = 0; const auto& res = m_wallet->createTransaction(vecSend, m_coin_control, true, nChangePosRet, nFeeRequired); @@ -120,7 +164,7 @@ bool WalletQmlModel::prepareTransaction() delete m_current_transaction; } CTransactionRef newTx = *res; - m_current_transaction = new WalletQmlModelTransaction(m_current_recipient, this); + m_current_transaction = new WalletQmlModelTransaction(m_send_recipients, this); m_current_transaction->setWtx(newTx); m_current_transaction->setTransactionFee(nFeeRequired); Q_EMIT currentTransactionChanged(); diff --git a/src/qml/models/walletqmlmodel.h b/src/qml/models/walletqmlmodel.h index d97cd0851f..33464fba7c 100644 --- a/src/qml/models/walletqmlmodel.h +++ b/src/qml/models/walletqmlmodel.h @@ -5,19 +5,22 @@ #ifndef BITCOIN_QML_MODELS_WALLETQMLMODEL_H #define BITCOIN_QML_MODELS_WALLETQMLMODEL_H -#include -#include #include #include +#include #include +#include #include + +#include +#include +#include #include -#include #include #include -class ActivityListModel; +#include class WalletQmlModel : public QObject { @@ -26,8 +29,9 @@ class WalletQmlModel : public QObject Q_PROPERTY(QString balance READ balance NOTIFY balanceChanged) Q_PROPERTY(ActivityListModel* activityListModel READ activityListModel CONSTANT) Q_PROPERTY(CoinsListModel* coinsListModel READ coinsListModel CONSTANT) - Q_PROPERTY(SendRecipient* sendRecipient READ sendRecipient CONSTANT) + Q_PROPERTY(SendRecipientsListModel* recipients READ sendRecipientList CONSTANT) Q_PROPERTY(WalletQmlModelTransaction* currentTransaction READ currentTransaction NOTIFY currentTransactionChanged) + Q_PROPERTY(PaymentRequest* currentPaymentRequest READ currentPaymentRequest CONSTANT) public: WalletQmlModel(std::unique_ptr wallet, QObject* parent = nullptr); @@ -36,8 +40,16 @@ class WalletQmlModel : public QObject QString name() const; QString balance() const; + CAmount balanceSatoshi() const; + Q_INVOKABLE void commitPaymentRequest(); + PaymentRequest* currentPaymentRequest() const { return m_current_payment_request; } + ActivityListModel* activityListModel() const { return m_activity_list_model; } CoinsListModel* coinsListModel() const { return m_coins_list_model; } + SendRecipientsListModel* sendRecipientList() const { return m_send_recipients; } + WalletQmlModelTransaction* currentTransaction() const { return m_current_transaction; } + Q_INVOKABLE bool prepareTransaction(); + Q_INVOKABLE void sendTransaction(); std::set getWalletTxs() const; interfaces::WalletTx getWalletTx(const uint256& hash) const; @@ -46,11 +58,6 @@ class WalletQmlModel : public QObject int& num_blocks, int64_t& block_time) const; - SendRecipient* sendRecipient() const { return m_current_recipient; } - WalletQmlModelTransaction* currentTransaction() const { return m_current_transaction; } - Q_INVOKABLE bool prepareTransaction(); - Q_INVOKABLE void sendTransaction(); - using TransactionChangedFn = std::function; virtual std::unique_ptr handleTransactionChanged(TransactionChangedFn fn); @@ -70,10 +77,13 @@ class WalletQmlModel : public QObject void currentTransactionChanged(); private: + static unsigned int m_next_payment_request_id; + std::unique_ptr m_wallet; + PaymentRequest* m_current_payment_request{nullptr}; ActivityListModel* m_activity_list_model{nullptr}; CoinsListModel* m_coins_list_model{nullptr}; - SendRecipient* m_current_recipient{nullptr}; + SendRecipientsListModel* m_send_recipients{nullptr}; WalletQmlModelTransaction* m_current_transaction{nullptr}; wallet::CCoinControl m_coin_control; }; diff --git a/src/qml/models/walletqmlmodeltransaction.cpp b/src/qml/models/walletqmlmodeltransaction.cpp index 199103377a..11cc34d08c 100644 --- a/src/qml/models/walletqmlmodeltransaction.cpp +++ b/src/qml/models/walletqmlmodeltransaction.cpp @@ -4,11 +4,13 @@ #include +#include +#include + #include -#include -WalletQmlModelTransaction::WalletQmlModelTransaction(const SendRecipient* recipient, QObject* parent) - : QObject(parent), m_address(recipient->address()), m_amount(recipient->cAmount()), m_fee(0), m_label(recipient->label()), m_wtx(nullptr) +WalletQmlModelTransaction::WalletQmlModelTransaction(const SendRecipientsListModel* recipient, QObject* parent) + : QObject(parent), m_address(recipient->recipients().at(0)->address()), m_amount(recipient->totalAmountSatoshi()), m_fee(0), m_label(recipient->recipients().at(0)->label()), m_wtx(nullptr) { } diff --git a/src/qml/models/walletqmlmodeltransaction.h b/src/qml/models/walletqmlmodeltransaction.h index 7bf914e06a..35112249de 100644 --- a/src/qml/models/walletqmlmodeltransaction.h +++ b/src/qml/models/walletqmlmodeltransaction.h @@ -5,12 +5,10 @@ #ifndef BITCOIN_QML_MODELS_WALLETQMLMODELTRANSACTION_H #define BITCOIN_QML_MODELS_WALLETQMLMODELTRANSACTION_H -#include -#include +#include #include - -#include +#include class WalletQmlModelTransaction : public QObject @@ -22,7 +20,7 @@ class WalletQmlModelTransaction : public QObject Q_PROPERTY(QString fee READ fee NOTIFY feeChanged) Q_PROPERTY(QString total READ total NOTIFY totalChanged) public: - explicit WalletQmlModelTransaction(const SendRecipient* recipient, QObject* parent = nullptr); + explicit WalletQmlModelTransaction(const SendRecipientsListModel* recipient, QObject* parent = nullptr); QString address() const; QString amount() const; @@ -30,8 +28,6 @@ class WalletQmlModelTransaction : public QObject QString label() const; QString total() const; - QList getRecipients() const; - CTransactionRef& getWtx(); void setWtx(const CTransactionRef&); diff --git a/src/qml/pages/main.qml b/src/qml/pages/main.qml index 60aa6c2705..710e2c3bae 100644 --- a/src/qml/pages/main.qml +++ b/src/qml/pages/main.qml @@ -85,7 +85,11 @@ ApplicationWindow { main.push(createWalletWizard) } onSendTransaction: { - main.push(sendReviewPage) + if (multipleRecipientsEnabled) { + main.push(multipleSendReviewPage) + } else { + main.push(sendReviewPage) + } } } } @@ -106,7 +110,21 @@ ApplicationWindow { main.pop() } onTransactionSent: { - walletController.selectedWallet.sendRecipient.clear() + walletController.selectedWallet.recipients.clear() + main.pop() + sendResult.open() + } + } + } + + Component { + id: multipleSendReviewPage + MultipleSendReview { + onBack: { + main.pop() + } + onTransactionSent: { + walletController.selectedWallet.recipients.clear() main.pop() sendResult.open() } diff --git a/src/qml/pages/wallet/DesktopWallets.qml b/src/qml/pages/wallet/DesktopWallets.qml index c9fe742993..f1ea7b76da 100644 --- a/src/qml/pages/wallet/DesktopWallets.qml +++ b/src/qml/pages/wallet/DesktopWallets.qml @@ -20,7 +20,7 @@ Page { ButtonGroup { id: navigationTabs } signal addWallet() - signal sendTransaction() + signal sendTransaction(bool multipleRecipientsEnabled) header: NavigationBar2 { id: navBar @@ -136,7 +136,7 @@ Page { } Send { id: sendTab - onTransactionPrepared: root.sendTransaction() + onTransactionPrepared: root.sendTransaction(multipleRecipientsEnabled) } RequestPayment { id: receiveTab diff --git a/src/qml/pages/wallet/MultipleSendReview.qml b/src/qml/pages/wallet/MultipleSendReview.qml new file mode 100644 index 0000000000..91f6ee2d96 --- /dev/null +++ b/src/qml/pages/wallet/MultipleSendReview.qml @@ -0,0 +1,155 @@ +// 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" + +Page { + id: root + background: null + + property WalletQmlModel wallet: walletController.selectedWallet + property WalletQmlModelTransaction transaction: walletController.selectedWallet.currentTransaction + + signal finished() + signal back() + signal transactionSent() + + header: NavigationBar2 { + id: navbar + leftItem: NavButton { + iconSource: "image://images/caret-left" + text: qsTr("Back") + onClicked: { + root.back() + } + } + } + + ScrollView { + clip: true + width: parent.width + height: parent.height + contentWidth: width + + ColumnLayout { + id: columnLayout + width: 450 + anchors.horizontalCenter: parent.horizontalCenter + + spacing: 15 + + CoreText { + id: title + Layout.topMargin: 30 + Layout.bottomMargin: 15 + text: qsTr("Transaction details") + font.pixelSize: 21 + bold: true + } + + ListView { + id: inputsList + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + model: root.wallet.recipients + delegate: Item { + id: delegate + height: 55 + width: ListView.view.width + + required property string address; + required property string label; + required property string amount; + + RowLayout { + spacing: 10 + anchors.fill: parent + CoreText { + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + text: label == "" ? address : label + font.pixelSize: 18 + elide: Text.ElideMiddle + } + + CoreText { + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + text: amount + font.pixelSize: 18 + } + } + + Separator { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + color: Theme.color.neutral3 + } + } + } + + RowLayout { + Layout.topMargin: 20 + CoreText { + text: qsTr("Total amount") + font.pixelSize: 20 + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignLeft + } + Item { + Layout.fillWidth: true + } + CoreText { + text: root.transaction.total + font.pixelSize: 20 + color: Theme.color.neutral9 + } + } + + Separator { + Layout.fillWidth: true + color: Theme.color.neutral3 + } + + RowLayout { + CoreText { + text: qsTr("Fee") + font.pixelSize: 18 + Layout.preferredWidth: 110 + horizontalAlignment: Text.AlignLeft + } + Item { + Layout.fillWidth: true + } + CoreText { + text: root.transaction.fee + font.pixelSize: 15 + } + } + + Separator { + Layout.fillWidth: true + color: Theme.color.neutral3 + } + + ContinueButton { + id: confimationButton + Layout.fillWidth: true + Layout.topMargin: 30 + text: qsTr("Send") + onClicked: { + root.wallet.sendTransaction() + root.transactionSent() + } + } + } + } +} diff --git a/src/qml/pages/wallet/RequestPayment.qml b/src/qml/pages/wallet/RequestPayment.qml index dc8f9b04ea..e7056a62ba 100644 --- a/src/qml/pages/wallet/RequestPayment.qml +++ b/src/qml/pages/wallet/RequestPayment.qml @@ -15,231 +15,166 @@ Page { id: root background: null - property int requestCounter: 0 + property WalletQmlModel wallet: walletController.selectedWallet + property PaymentRequest request: wallet.currentPaymentRequest ScrollView { clip: true - width: parent.width - height: parent.height + anchors.fill: parent contentWidth: width - CoreText { - id: title - anchors.left: contentRow.left + ColumnLayout { + id: scrollContent + anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: 20 - text: qsTr("Request a payment") - font.pixelSize: 21 - bold: true - } + spacing: 20 + + CoreText { + id: title + Layout.alignment: Qt.AlignLeft + text: root.request.id === "" + ? qsTr("Request a payment") + : qsTr("Payment request #") + root.request.id + font.pixelSize: 21 + bold: true + } - RowLayout { - id: contentRow + RowLayout { + id: contentRow + Layout.fillWidth: true + enabled: walletController.initialized + spacing: 30 - enabled: walletController.initialized + ColumnLayout { + id: formColumn + Layout.minimumWidth: 450 + Layout.maximumWidth: 450 - anchors.top: title.bottom - anchors.topMargin: 40 - anchors.horizontalCenter: parent.horizontalCenter - spacing: 30 - ColumnLayout { - id: columnLayout - Layout.minimumWidth: 450 - Layout.maximumWidth: 470 + spacing: 10 - spacing: 5 + BitcoinAmountInputField { + Layout.fillWidth: true + enabled: walletController.initialized + amount: root.request.amount + errorText: root.request.amountError + } - Item { - BitcoinAmount { - id: bitcoinAmount + Separator { + Layout.fillWidth: true } - height: amountInput.height - Layout.fillWidth: true - CoreText { - id: amountLabel - width: 110 - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - horizontalAlignment: Text.AlignLeft - text: "Amount" - font.pixelSize: 18 + LabeledTextInput { + id: label + Layout.fillWidth: true + labelText: qsTr("Label") + placeholderText: qsTr("Enter label...") } - TextField { - id: amountInput - anchors.left: amountLabel.right - anchors.verticalCenter: parent.verticalCenter - leftPadding: 0 - font.family: "Inter" - font.styleName: "Regular" - font.pixelSize: 18 - color: Theme.color.neutral9 - placeholderTextColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 - background: Item {} - placeholderText: "0.00000000" - selectByMouse: true - onTextEdited: { - amountInput.text = bitcoinAmount.sanitize(amountInput.text) - } + Separator { + Layout.fillWidth: true } - 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: enabled ? Theme.color.neutral7 : Theme.color.neutral4 - } - Icon { - id: flipIcon - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - source: "image://images/flip-vertical" - color: unitLabel.enabled ? Theme.color.neutral8 : Theme.color.neutral4 - size: 30 - } + + LabeledTextInput { + id: message + Layout.fillWidth: true + labelText: qsTr("Message") + placeholderText: qsTr("Enter message...") } - } - Separator { - Layout.fillWidth: true - } + Separator { + Layout.fillWidth: true + } - LabeledTextInput { - id: label - Layout.fillWidth: true - labelText: qsTr("Label") - placeholderText: qsTr("Enter label...") - } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 10 - Separator { - Layout.fillWidth: true - } + spacing: 0 - LabeledTextInput { - id: message - Layout.fillWidth: true - labelText: qsTr("Message") - placeholderText: qsTr("Enter message...") - } + ColumnLayout { + spacing: 5 + Layout.alignment: Qt.AlignLeft + Layout.minimumWidth: 110 - Separator { - Layout.fillWidth: true - } - - Item { - Layout.fillWidth: true - Layout.minimumHeight: addressLabel.height + copyLabel.height - Layout.topMargin: 10 - height: addressLabel.height + copyLabel.height - CoreText { - id: addressLabel - anchors.left: parent.left - anchors.top: parent.top - horizontalAlignment: Text.AlignLeft - width: 110 - text: qsTr("Address") - font.pixelSize: 18 - } - CoreText { - id: copyLabel - anchors.left: parent.left - anchors.top: addressLabel.bottom - horizontalAlignment: Text.AlignLeft - width: 110 - text: qsTr("copy") - font.pixelSize: 18 - color: enabled ? Theme.color.neutral7 : Theme.color.neutral4 - } + CoreText { + id: addressLabel + text: qsTr("Address") + font.pixelSize: 18 + } + CoreText { + id: copyLabel + text: qsTr("copy") + font.pixelSize: 18 + color: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + } + } - Rectangle { - anchors.left: addressLabel.right - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - color: Theme.color.neutral2 - radius: 5 CoreText { id: address - anchors.fill: parent - anchors.leftMargin: 5 + Layout.fillWidth: true + Layout.minimumHeight: 50 + text: root.request.address horizontalAlignment: Text.AlignLeft font.pixelSize: 18 - wrap: true + wrapMode: Text.WrapAnywhere + background: Rectangle { + color: Theme.color.neutral2 + radius: 5 + } } } - } - ContinueButton { - id: continueButton - Layout.fillWidth: true - Layout.topMargin: 30 - text: qsTr("Create bitcoin address") - onClicked: { - if (!clearRequest.visible) { - requestCounter = requestCounter + 1 - clearRequest.visible = true - title.text = qsTr("Payment request #" + requestCounter) - address.text = "bc1q f5xe y2tf 89k9 zy6k gnru wszy 5fsa truy 9te1 bu" - qrImage.code = "bc1qf5xey2tf89k9zy6kgnruwszy5fsatruy9te1bu" - continueButton.text = qsTr("Copy payment request") + ContinueButton { + id: continueButton + Layout.fillWidth: true + Layout.topMargin: 30 + text: qsTr("Create bitcoin address") + onClicked: { + if (!clearRequest.visible) { + clearRequest.visible = true + root.wallet.commitPaymentRequest() + title.text = qsTr("Payment request #" + root.wallet.request.id) + continueButton.text = qsTr("Copy payment request") + } } } - } - ContinueButton { - id: clearRequest - Layout.fillWidth: true - Layout.topMargin: 10 - visible: false - borderColor: Theme.color.neutral6 - borderHoverColor: Theme.color.orangeLight1 - borderPressedColor: Theme.color.orangeLight2 - backgroundColor: "transparent" - backgroundHoverColor: "transparent" - backgroundPressedColor: "transparent" - text: qsTr("Clear") - onClicked: { - clearRequest.visible = false - title.text = qsTr("Request a payment") - address.text = "" - qrImage.code = "" - continueButton.text = qsTr("Create bitcoin address") + ContinueButton { + id: clearRequest + Layout.fillWidth: true + Layout.topMargin: 10 + visible: false + borderColor: Theme.color.neutral6 + borderHoverColor: Theme.color.orangeLight1 + borderPressedColor: Theme.color.orangeLight2 + backgroundColor: "transparent" + backgroundHoverColor: "transparent" + backgroundPressedColor: "transparent" + text: qsTr("Clear") + onClicked: { + clearRequest.visible = false + root.request.clear() + continueButton.text = qsTr("Create bitcoin address") + } } } - } - Pane { - Layout.alignment: Qt.AlignTop - Layout.minimumWidth: 150 - Layout.minimumHeight: 150 - padding: 0 - background: Rectangle { - color: Theme.color.neutral2 - visible: qrImage.code === "" - } - contentItem: QRImage { - id: qrImage - backgroundColor: "transparent" - foregroundColor: Theme.color.neutral9 + Pane { + Layout.alignment: Qt.AlignTop + Layout.minimumWidth: 150 + Layout.minimumHeight: 150 + padding: 0 + background: Rectangle { + color: Theme.color.neutral2 + visible: qrImage.code === "" + } + contentItem: QRImage { + id: qrImage + backgroundColor: "transparent" + foregroundColor: Theme.color.neutral9 + code: root.request.address + } } } } diff --git a/src/qml/pages/wallet/Send.qml b/src/qml/pages/wallet/Send.qml index 9c1905db99..eed2d33271 100644 --- a/src/qml/pages/wallet/Send.qml +++ b/src/qml/pages/wallet/Send.qml @@ -16,9 +16,9 @@ PageStack { vertical: true property WalletQmlModel wallet: walletController.selectedWallet - property SendRecipient recipient: wallet.sendRecipient + property SendRecipient recipient: wallet.recipients.current - signal transactionPrepared() + signal transactionPrepared(bool multipleRecipientsEnabled) Connections { target: walletController @@ -33,12 +33,14 @@ PageStack { Settings { id: settings property alias coinControlEnabled: sendOptionsPopup.coinControlEnabled + property alias multipleRecipientsEnabled: sendOptionsPopup.multipleRecipientsEnabled } ScrollView { clip: true width: parent.width height: parent.height + contentWidth: width ColumnLayout { @@ -55,6 +57,7 @@ PageStack { Layout.fillWidth: true Layout.topMargin: 30 Layout.bottomMargin: 20 + CoreText { id: title anchors.left: parent.left @@ -64,11 +67,13 @@ PageStack { color: Theme.color.neutral9 bold: true } - EllipsisMenuButton { + + IconButton { id: menuButton anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter checked: sendOptionsPopup.opened + iconSource: "image://images/ellipsis" onClicked: { sendOptionsPopup.open() } @@ -78,91 +83,105 @@ PageStack { id: sendOptionsPopup x: menuButton.x - width + menuButton.width y: menuButton.y + menuButton.height - width: 300 - height: 50 } } - LabeledTextInput { - id: address - Layout.fillWidth: true - labelText: qsTr("Send to") - placeholderText: qsTr("Enter address...") - text: root.recipient.address - onTextEdited: root.recipient.address = address.text - } - - Separator { + RowLayout { + id: selectAndAddRecipients Layout.fillWidth: true - } + Layout.topMargin: 10 + Layout.bottomMargin: 10 + visible: settings.multipleRecipientsEnabled - Item { - BitcoinAmount { - id: bitcoinAmount - } - - height: amountInput.height - Layout.fillWidth: true CoreText { - id: amountLabel - width: 110 - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + id: selectAndAddRecipientsLabel + text: qsTr("Recipient %1 of %2").arg(wallet.recipients.currentIndex).arg(wallet.recipients.count) horizontalAlignment: Text.AlignLeft - text: qsTr("Amount") font.pixelSize: 18 + color: Theme.color.neutral9 } - TextField { - id: amountInput - anchors.left: amountLabel.right - anchors.verticalCenter: parent.verticalCenter - leftPadding: 0 - font.family: "Inter" - font.styleName: "Regular" - font.pixelSize: 18 - color: Theme.color.neutral9 - placeholderTextColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 - background: Item {} - placeholderText: "0.00000000" - selectByMouse: true - onTextEdited: { - amountInput.text = bitcoinAmount.amount = bitcoinAmount.sanitize(amountInput.text) - root.recipient.amount = bitcoinAmount.satoshiAmount + IconButton { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + size: 30 + iconSource: "image://images/caret-left" + enabled: wallet.recipients.currentIndex - 1 > 0 + onClicked: { + wallet.recipients.prev() } } - 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 - } - } + + IconButton { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + size: 30 + iconSource: "image://images/caret-right" + enabled: wallet.recipients.currentIndex < wallet.recipients.count + onClicked: { + wallet.recipients.next() } - CoreText { - id: unitLabel - anchors.right: flipIcon.left - anchors.verticalCenter: parent.verticalCenter - text: bitcoinAmount.unitLabel - font.pixelSize: 18 - color: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + } + + IconButton { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + size: 30 + iconSource: "image://images/plus-big-filled" + onClicked: { + wallet.recipients.add() } + } + + IconButton { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + size: 30 + iconSource: "image://images/minus" + enabled: wallet.recipients.count > 1 + onClicked: { + wallet.recipients.remove() + } + } + } + + Separator { + visible: settings.multipleRecipientsEnabled + Layout.fillWidth: true + } + + ColumnLayout { + Layout.fillWidth: true + + LabeledTextInput { + id: address + Layout.fillWidth: true + labelText: qsTr("Send to") + placeholderText: qsTr("Enter address...") + text: root.recipient.address + onTextEdited: root.recipient.address = address.text + } + + RowLayout { + id: addressIssue + Layout.fillWidth: true + visible: root.recipient.addressError.length > 0 + Icon { - id: flipIcon - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - source: "image://images/flip-vertical" - icon.color: unitLabel.enabled ? Theme.color.neutral8 : Theme.color.neutral4 - size: 30 + source: "image://images/alert-filled" + size: 22 + color: Theme.color.red + } + + CoreText { + id: warningText + text: root.recipient.addressError + font.pixelSize: 15 + color: Theme.color.red + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true } } } @@ -171,6 +190,17 @@ PageStack { Layout.fillWidth: true } + BitcoinAmountInputField { + Layout.fillWidth: true + enabled: walletController.initialized + amount: root.recipient.amount + errorText: root.recipient.amountError + } + + Separator { + Layout.fillWidth: true + } + LabeledTextInput { id: label Layout.fillWidth: true @@ -224,9 +254,10 @@ PageStack { Layout.fillWidth: true Layout.topMargin: 30 text: qsTr("Review") + enabled: root.recipient.isValid onClicked: { if (root.wallet.prepareTransaction()) { - root.transactionPrepared() + root.transactionPrepared(settings.multipleRecipientsEnabled); } } } diff --git a/src/qml/res/icons/alert-filled.png b/src/qml/res/icons/alert-filled.png new file mode 100644 index 0000000000..a097bea42c Binary files /dev/null and b/src/qml/res/icons/alert-filled.png differ diff --git a/src/qml/res/icons/plus-big-filled.png b/src/qml/res/icons/plus-big-filled.png new file mode 100644 index 0000000000..365ed049e5 Binary files /dev/null and b/src/qml/res/icons/plus-big-filled.png differ diff --git a/src/qml/res/src/alert-filled.svg b/src/qml/res/src/alert-filled.svg new file mode 100644 index 0000000000..f556ef71ca --- /dev/null +++ b/src/qml/res/src/alert-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/qml/res/src/plus-big-filled.svg b/src/qml/res/src/plus-big-filled.svg new file mode 100644 index 0000000000..2efe7ba2c5 --- /dev/null +++ b/src/qml/res/src/plus-big-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/lint/lint-circular-dependencies.py b/test/lint/lint-circular-dependencies.py index fa98b6fd69..b2b7540d97 100755 --- a/test/lint/lint-circular-dependencies.py +++ b/test/lint/lint-circular-dependencies.py @@ -17,6 +17,10 @@ "node/utxo_snapshot -> validation -> node/utxo_snapshot", "qml/models/activitylistmodel -> qml/models/walletqmlmodel -> qml/models/activitylistmodel", "qml/models/coinslistmodel -> qml/models/walletqmlmodel -> qml/models/coinslistmodel", + "qml/models/sendrecipient -> qml/models/walletqmlmodel -> qml/models/sendrecipient", + "qml/models/sendrecipient -> qml/models/walletqmlmodel -> qml/models/walletqmlmodeltransaction -> qml/models/sendrecipient", + "qml/models/sendrecipientslistmodel -> qml/models/walletqmlmodel -> qml/models/sendrecipientslistmodel", + "qml/models/sendrecipientslistmodel -> qml/models/walletqmlmodel -> qml/models/walletqmlmodeltransaction -> qml/models/sendrecipientslistmodel", "qt/addresstablemodel -> qt/walletmodel -> qt/addresstablemodel", "qt/recentrequeststablemodel -> qt/walletmodel -> qt/recentrequeststablemodel", "qt/sendcoinsdialog -> qt/walletmodel -> qt/sendcoinsdialog",