diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index f3cbb813cf..7a29f7b3e2 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -357,6 +357,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 \ diff --git a/src/qml/bitcoin_qml.qrc b/src/qml/bitcoin_qml.qrc index edeede29e3..52cff573e2 100644 --- a/src/qml/bitcoin_qml.qrc +++ b/src/qml/bitcoin_qml.qrc @@ -101,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 diff --git a/src/qml/controls/LabeledTextInput.qml b/src/qml/controls/LabeledTextInput.qml index c257ec9a7d..419c98d422 100644 --- a/src/qml/controls/LabeledTextInput.qml +++ b/src/qml/controls/LabeledTextInput.qml @@ -13,6 +13,8 @@ Item { property alias iconSource: icon.source property alias customIcon: iconContainer.data property alias enabled: input.enabled + property alias validator: input.validator + property alias maximumLength: input.maximumLength signal iconClicked signal textEdited diff --git a/src/qml/models/sendrecipient.cpp b/src/qml/models/sendrecipient.cpp index ce4943d099..e98c9b9aff 100644 --- a/src/qml/models/sendrecipient.cpp +++ b/src/qml/models/sendrecipient.cpp @@ -5,10 +5,14 @@ #include #include +#include -SendRecipient::SendRecipient(QObject* parent) - : QObject(parent), m_amount(new BitcoinAmount(this)) +#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 @@ -21,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(); } } @@ -42,6 +60,19 @@ BitcoinAmount* SendRecipient::amount() const return m_amount; } +QString SendRecipient::amountError() const +{ + return m_amountError; +} + +void SendRecipient::setAmountError(const QString& error) +{ + if (m_amountError != error) { + m_amountError = error; + Q_EMIT amountErrorChanged(); + } +} + QString SendRecipient::message() const { return m_message; @@ -77,3 +108,42 @@ void SendRecipient::clear() Q_EMIT messageChanged(); Q_EMIT amount()->amountChanged(); } + +void SendRecipient::validateAddress() +{ + if (!m_address.isEmpty() && !IsValidDestinationString(m_address.toStdString())) { + if (IsValidDestinationString(m_address.toStdString(), *CChainParams::Main())) { + setAddressError(tr("Address is valid for mainnet, not the current network")); + } else if (IsValidDestinationString(m_address.toStdString(), *CChainParams::TestNet())) { + setAddressError(tr("Address is valid for testnet, not the current network")); + } else { + setAddressError(tr("Invalid address format")); + } + } else { + setAddressError(""); + } + + Q_EMIT isValidChanged(); +} + +void SendRecipient::validateAmount() +{ + 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 of 21,000,000 BTC")); + } else if (m_amount->satoshi() > m_wallet->balanceSatoshi()) { + setAmountError(tr("Amount exceeds available balance")); + } else { + setAmountError(""); + } + } + + 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 80af868a1c..972914a354 100644 --- a/src/qml/models/sendrecipient.h +++ b/src/qml/models/sendrecipient.h @@ -10,6 +10,8 @@ #include #include +class WalletQmlModel; + class SendRecipient : public QObject { Q_OBJECT @@ -18,17 +20,25 @@ class SendRecipient : public QObject 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); BitcoinAmount* amount() const; void setAmount(const QString& amount); + QString amountError() const; + void setAmountError(const QString& error); QString message() const; void setMessage(const QString& message); @@ -37,18 +47,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 messageChanged(); + void isValidChanged(); private: + void validateAddress(); + void validateAmount(); + + const 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 index c4c4d0a683..eea97bc60e 100644 --- a/src/qml/models/sendrecipientslistmodel.cpp +++ b/src/qml/models/sendrecipientslistmodel.cpp @@ -3,13 +3,15 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include SendRecipientsListModel::SendRecipientsListModel(QObject* parent) : QAbstractListModel(parent) { - auto* recipient = new SendRecipient(this); + m_wallet = qobject_cast(parent); + auto* recipient = new SendRecipient(m_wallet, this); connect(recipient->amount(), &BitcoinAmount::amountChanged, this, &SendRecipientsListModel::updateTotalAmount); m_recipients.append(recipient); @@ -50,7 +52,7 @@ void SendRecipientsListModel::add() { const int row = m_recipients.size(); beginInsertRows(QModelIndex(), row, row); - auto* recipient = new SendRecipient(this); + auto* recipient = new SendRecipient(m_wallet, this); connect(recipient->amount(), &BitcoinAmount::amountChanged, this, &SendRecipientsListModel::updateTotalAmount); if (m_recipients.size() > 0) { @@ -137,7 +139,7 @@ void SendRecipientsListModel::clear() m_current = 0; m_totalAmount = 0; - auto* recipient = new SendRecipient(this); + auto* recipient = new SendRecipient(m_wallet, this); connect(recipient->amount(), &BitcoinAmount::amountChanged, this, &SendRecipientsListModel::updateTotalAmount); m_recipients.append(recipient); diff --git a/src/qml/models/sendrecipientslistmodel.h b/src/qml/models/sendrecipientslistmodel.h index 0e9b77d4b7..1919626b51 100644 --- a/src/qml/models/sendrecipientslistmodel.h +++ b/src/qml/models/sendrecipientslistmodel.h @@ -5,11 +5,10 @@ #ifndef BITCOIN_QML_MODELS_SENDRECIPIENTSLISTMODEL_H #define BITCOIN_QML_MODELS_SENDRECIPIENTSLISTMODEL_H -#include - #include -#include -#include + +class SendRecipient; +class WalletQmlModel; class SendRecipientsListModel : public QAbstractListModel { @@ -58,6 +57,7 @@ class SendRecipientsListModel : public QAbstractListModel private: void updateTotalAmount(); + WalletQmlModel* m_wallet; QList m_recipients; int m_current{0}; qint64 m_totalAmount{0}; diff --git a/src/qml/models/walletqmlmodel.cpp b/src/qml/models/walletqmlmodel.cpp index f05b2b38bb..e54d0a0e4d 100644 --- a/src/qml/models/walletqmlmodel.cpp +++ b/src/qml/models/walletqmlmodel.cpp @@ -55,6 +55,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) { diff --git a/src/qml/models/walletqmlmodel.h b/src/qml/models/walletqmlmodel.h index 35b86d643a..061464cf11 100644 --- a/src/qml/models/walletqmlmodel.h +++ b/src/qml/models/walletqmlmodel.h @@ -5,20 +5,21 @@ #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 -class ActivityListModel; +#include class WalletQmlModel : public QObject { @@ -39,6 +40,8 @@ class WalletQmlModel : public QObject QString name() const; QString balance() const; + CAmount balanceSatoshi() const; + ActivityListModel* activityListModel() const { return m_activity_list_model; } CoinsListModel* coinsListModel() const { return m_coins_list_model; } SendRecipientsListModel* sendRecipientList() const { return m_send_recipients; } diff --git a/src/qml/models/walletqmlmodeltransaction.cpp b/src/qml/models/walletqmlmodeltransaction.cpp index 2303606a0e..11cc34d08c 100644 --- a/src/qml/models/walletqmlmodeltransaction.cpp +++ b/src/qml/models/walletqmlmodeltransaction.cpp @@ -4,6 +4,9 @@ #include +#include +#include + #include WalletQmlModelTransaction::WalletQmlModelTransaction(const SendRecipientsListModel* recipient, QObject* parent) diff --git a/src/qml/pages/wallet/Send.qml b/src/qml/pages/wallet/Send.qml index 65b3b86cdf..ab66e67779 100644 --- a/src/qml/pages/wallet/Send.qml +++ b/src/qml/pages/wallet/Send.qml @@ -127,7 +127,6 @@ PageStack { enabled: wallet.recipients.currentIndex - 1 > 0 onClicked: { wallet.recipients.prev() - } } @@ -170,78 +169,134 @@ PageStack { Layout.fillWidth: true } - LabeledTextInput { - id: address + ColumnLayout { Layout.fillWidth: true - labelText: qsTr("Send to") - placeholderText: qsTr("Enter address...") - text: root.recipient.address - onTextEdited: root.recipient.address = address.text + + LabeledTextInput { + id: address + Layout.fillWidth: true + labelText: qsTr("Send to") + placeholderText: qsTr("Enter address...") + text: root.recipient.address + onTextEdited: root.recipient.address = address.text + validator: RegExpValidator { + regExp: /^[1-9A-HJ-NP-Za-km-zac-hj-np-z02-9]+$/ + } + maximumLength: 62 + } + + RowLayout { + id: addressIssue + Layout.fillWidth: true + visible: root.recipient.addressError.length > 0 + + Icon { + 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 + } + } } Separator { Layout.fillWidth: true } - Item { - height: amountInput.height + ColumnLayout { Layout.fillWidth: true - CoreText { - id: amountLabel - width: 110 - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - horizontalAlignment: Text.AlignLeft - text: qsTr("Amount") - font.pixelSize: 18 - } - 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 - 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 { - 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: root.recipient.amount.flipUnit() - } + height: amountInput.height + Layout.fillWidth: true CoreText { - id: unitLabel - anchors.right: flipIcon.left + id: amountLabel + width: 110 + anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - text: root.recipient.amount.unitLabel + horizontalAlignment: Text.AlignLeft + text: qsTr("Amount") font.pixelSize: 18 - color: enabled ? Theme.color.neutral7 : Theme.color.neutral4 } - Icon { - id: flipIcon + + 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 + text: root.recipient.amount.display + onTextEdited: root.recipient.amount.display = text + onEditingFinished: root.recipient.amount.format() + onActiveFocusChanged: { + if (!activeFocus) { + root.recipient.amount.format() + } + } + validator: RegExpValidator { + regExp: /^(0|[1-9]\d*)(\.\d{0,8})?$/ + } + maximumLength: 17 + } + Item { + width: unitLabel.width + flipIcon.width + height: Math.max(unitLabel.height, flipIcon.height) 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 + MouseArea { + anchors.fill: parent + onClicked: root.recipient.amount.flipUnit() + } + CoreText { + id: unitLabel + anchors.right: flipIcon.left + anchors.verticalCenter: parent.verticalCenter + text: root.recipient.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: unitLabel.enabled ? Theme.color.neutral8 : Theme.color.neutral4 + size: 30 + } + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.recipient.amountError.length > 0 + + Icon { + source: "image://images/alert-filled" + size: 22 + color: Theme.color.red + } + + CoreText { + text: root.recipient.amountError + font.pixelSize: 15 + color: Theme.color.red + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true } } } @@ -293,6 +348,7 @@ PageStack { Layout.fillWidth: true Layout.topMargin: 30 text: qsTr("Review") + enabled: root.recipient.isValid onClicked: { if (root.wallet.prepareTransaction()) { 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/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/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",