diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 306dbe2aa6..019b2bd15c 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -44,7 +44,9 @@ QT_MOC_CPP = \ qml/models/moc_nodemodel.cpp \ qml/models/moc_options_model.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 \ @@ -138,6 +140,7 @@ BITCOIN_QT_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 \ @@ -339,6 +342,7 @@ BITCOIN_QML_BASE_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 \ @@ -379,6 +383,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 \ @@ -420,12 +425,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 +489,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_qml.qrc b/src/qml/bitcoin_qml.qrc index 8557ebe0e6..6bcd5f0705 100644 --- a/src/qml/bitcoin_qml.qrc +++ b/src/qml/bitcoin_qml.qrc @@ -30,12 +30,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 +90,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 @@ -126,6 +127,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..3cc31605d8 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; @@ -52,103 +66,94 @@ void BitcoinAmount::setUnit(const Unit unit) { m_unit = unit; Q_EMIT unitChanged(); + Q_EMIT displayChanged(); } 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 displayChanged(); } -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); + if (btcSanitized.isEmpty() || btcSanitized == ".") return 0; - QStringList parts = amount.remove(' ').split("."); + QString cleaned = btcSanitized; + if (cleaned.startsWith('.')) cleaned.prepend('0'); - QString whole = parts[0]; - QString decimals; - - 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(); } + setSatoshi(newSat); +} - return result; +void BitcoinAmount::format() +{ + Q_EMIT displayChanged(); } diff --git a/src/qml/bitcoinamount.h b/src/qml/bitcoinamount.h index 0631a05b87..c451b34fd3 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 displayChanged) + Q_PROPERTY(qint64 satoshi READ satoshi WRITE setSatoshi NOTIFY amountChanged) public: enum class Unit { @@ -30,27 +30,34 @@ 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; } + + Q_INVOKABLE void format(); + + 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(); + void displayChanged(); 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/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..10df825620 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.neutral4 } + 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/sendrecipient.cpp b/src/qml/models/sendrecipient.cpp index 138bea6559..ce4943d099 100644 --- a/src/qml/models/sendrecipient.cpp +++ b/src/qml/models/sendrecipient.cpp @@ -3,10 +3,11 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include -#include + +#include SendRecipient::SendRecipient(QObject* parent) - : QObject(parent), m_address(""), m_label(""), m_amount(""), m_message("") + : QObject(parent), m_amount(new BitcoinAmount(this)) { } @@ -36,19 +37,11 @@ void SendRecipient::setLabel(const QString& label) } } -QString SendRecipient::amount() const +BitcoinAmount* SendRecipient::amount() const { return m_amount; } -void SendRecipient::setAmount(const QString& amount) -{ - if (m_amount != amount) { - m_amount = amount; - Q_EMIT amountChanged(); - } -} - QString SendRecipient::message() const { return m_message; @@ -69,22 +62,18 @@ 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(); } diff --git a/src/qml/models/sendrecipient.h b/src/qml/models/sendrecipient.h index 042e97c9de..80af868a1c 100644 --- a/src/qml/models/sendrecipient.h +++ b/src/qml/models/sendrecipient.h @@ -5,17 +5,18 @@ #ifndef BITCOIN_QML_MODELS_SENDRECIPIENT_H #define BITCOIN_QML_MODELS_SENDRECIPIENT_H +#include + #include #include -#include 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) public: explicit SendRecipient(QObject* parent = nullptr); @@ -26,7 +27,7 @@ class SendRecipient : public QObject QString label() const; void setLabel(const QString& label); - QString amount() const; + BitcoinAmount* amount() const; void setAmount(const QString& amount); QString message() const; @@ -41,14 +42,13 @@ class SendRecipient : public QObject Q_SIGNALS: void addressChanged(); void labelChanged(); - void amountChanged(); void messageChanged(); private: - QString m_address; - QString m_label; - QString m_amount; - QString m_message; + QString m_address{""}; + QString m_label{""}; + QString m_message{""}; + BitcoinAmount* m_amount; bool m_subtractFeeFromAmount{false}; }; diff --git a/src/qml/models/sendrecipientslistmodel.cpp b/src/qml/models/sendrecipientslistmodel.cpp new file mode 100644 index 0000000000..c4c4d0a683 --- /dev/null +++ b/src/qml/models/sendrecipientslistmodel.cpp @@ -0,0 +1,176 @@ +// 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 + +SendRecipientsListModel::SendRecipientsListModel(QObject* parent) + : QAbstractListModel(parent) +{ + auto* recipient = new SendRecipient(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(this); + connect(recipient->amount(), &BitcoinAmount::amountChanged, + this, &SendRecipientsListModel::updateTotalAmount); + if (m_recipients.size() > 0) { + recipient->amount()->setUnit(m_recipients[m_current]->amount()->unit()); + } + 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(); + + if (m_current > 0) { + setCurrentIndex(m_current - 1); + } else { + Q_EMIT currentRecipientChanged(); + } +} + +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(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(); + Q_EMIT listCleared(); +} + +void SendRecipientsListModel::clearToFront() +{ + bool count_changed = false; + while (m_recipients.size() > 1) { + delete m_recipients.at(1); + m_recipients.removeAt(1); + count_changed = true; + } + + if (count_changed) { + Q_EMIT countChanged(); + } + + if (m_totalAmount != m_recipients[0]->amount()->satoshi()) { + m_totalAmount = m_recipients[0]->amount()->satoshi(); + Q_EMIT totalAmountChanged(); + } + + if (m_current != 0) { + m_current = 0; + 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..0e9b77d4b7 --- /dev/null +++ b/src/qml/models/sendrecipientslistmodel.h @@ -0,0 +1,66 @@ +// 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 + +#include +#include +#include + +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(); + Q_INVOKABLE void clearToFront(); + + 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(); + void listCleared(); + +private: + void updateTotalAmount(); + + 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..22162cd62d 100644 --- a/src/qml/models/walletqmlmodel.cpp +++ b/src/qml/models/walletqmlmodel.cpp @@ -1,3 +1,4 @@ + // 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. @@ -6,6 +7,7 @@ #include #include +#include #include #include @@ -24,7 +26,7 @@ WalletQmlModel::WalletQmlModel(std::unique_ptr wallet, QObje 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); } WalletQmlModel::WalletQmlModel(QObject* parent) @@ -32,14 +34,14 @@ 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); } WalletQmlModel::~WalletQmlModel() { delete m_activity_list_model; delete m_coins_list_model; - delete m_current_recipient; + delete m_send_recipients; if (m_current_transaction) { delete m_current_transaction; } @@ -98,20 +100,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 +127,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..de8c5540ac 100644 --- a/src/qml/models/walletqmlmodel.h +++ b/src/qml/models/walletqmlmodel.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -26,7 +27,7 @@ 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) public: @@ -38,6 +39,10 @@ class WalletQmlModel : public QObject QString balance() const; 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 +51,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); @@ -73,7 +73,7 @@ class WalletQmlModel : public QObject std::unique_ptr m_wallet; 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..2303606a0e 100644 --- a/src/qml/models/walletqmlmodeltransaction.cpp +++ b/src/qml/models/walletqmlmodeltransaction.cpp @@ -5,10 +5,9 @@ #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 d2bf0469b3..bdb6bb4ba6 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 @@ -132,7 +132,7 @@ Page { Activity { } Send { - onTransactionPrepared: root.sendTransaction() + onTransactionPrepared: root.sendTransaction(multipleRecipientsEnabled) } RequestPayment { } 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/Send.qml b/src/qml/pages/wallet/Send.qml index 9c1905db99..2e4841b0a0 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 @@ -27,18 +27,36 @@ PageStack { } } + Connections { + target: root.wallet.recipients + function onListCleared() { + settings.multipleRecipientsEnabled = false + } + } + + initialItem: Page { background: null Settings { id: settings property alias coinControlEnabled: sendOptionsPopup.coinControlEnabled + property alias multipleRecipientsEnabled: sendOptionsPopup.multipleRecipientsEnabled + + onMultipleRecipientsEnabledChanged: { + if (!multipleRecipientsEnabled) { + root.wallet.recipients.clearToFront() + } else { + root.wallet.recipients.add() + } + } } ScrollView { clip: true width: parent.width height: parent.height + contentWidth: width ColumnLayout { @@ -55,6 +73,7 @@ PageStack { Layout.fillWidth: true Layout.topMargin: 30 Layout.bottomMargin: 20 + CoreText { id: title anchors.left: parent.left @@ -64,11 +83,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,11 +99,77 @@ PageStack { id: sendOptionsPopup x: menuButton.x - width + menuButton.width y: menuButton.y + menuButton.height - width: 300 - height: 50 } } + RowLayout { + id: selectAndAddRecipients + Layout.fillWidth: true + Layout.topMargin: 10 + Layout.bottomMargin: 10 + visible: settings.multipleRecipientsEnabled + + CoreText { + 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 + font.pixelSize: 18 + color: Theme.color.neutral9 + } + + IconButton { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + size: 30 + iconSource: "image://images/caret-left" + enabled: wallet.recipients.currentIndex - 1 > 0 + onClicked: { + wallet.recipients.prev() + + } + } + + 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() + } + } + + IconButton { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + size: 30 + iconSource: "image://images/plus-big-filled" + enabled: wallet.recipients.count < 25 + 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 + } + LabeledTextInput { id: address Layout.fillWidth: true @@ -97,10 +184,6 @@ PageStack { } Item { - BitcoinAmount { - id: bitcoinAmount - } - height: amountInput.height Layout.fillWidth: true CoreText { @@ -126,9 +209,13 @@ PageStack { background: Item {} placeholderText: "0.00000000" selectByMouse: true - onTextEdited: { - amountInput.text = bitcoinAmount.amount = bitcoinAmount.sanitize(amountInput.text) - root.recipient.amount = bitcoinAmount.satoshiAmount + text: root.recipient.amount.display + onTextEdited: root.recipient.amount.display = text + onEditingFinished: root.recipient.amount.format() + onActiveFocusChanged: { + if (!activeFocus) { + root.recipient.amount.display = text + } } } Item { @@ -138,21 +225,13 @@ PageStack { 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 - } - } + onClicked: root.recipient.amount.flipUnit() } CoreText { id: unitLabel anchors.right: flipIcon.left anchors.verticalCenter: parent.verticalCenter - text: bitcoinAmount.unitLabel + text: root.recipient.amount.unitLabel font.pixelSize: 18 color: enabled ? Theme.color.neutral7 : Theme.color.neutral4 } @@ -176,6 +255,7 @@ PageStack { Layout.fillWidth: true labelText: qsTr("Note to self") placeholderText: qsTr("Enter ...") + text: root.recipient.label onTextEdited: root.recipient.label = label.text } @@ -226,7 +306,7 @@ PageStack { text: qsTr("Review") onClicked: { if (root.wallet.prepareTransaction()) { - root.transactionPrepared() + root.transactionPrepared(settings.multipleRecipientsEnabled); } } } 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/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 @@ + + +