From 46b81b30a63c392c14a06f09254f657bdfa5dd24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Tinkl?= Date: Mon, 10 Jun 2024 12:37:39 +0200 Subject: [PATCH] feat: [UI - Wallet Stability] Create Max button component - create a reusable "Max" send button component - use it in SwapModal and SendModal - add some more tests in tst_SwapInputPanel.qml Fixes #15066 --- storybook/pages/SwapInputPanelPage.qml | 2 +- .../qmlTests/tests/tst_SwapInputPanel.qml | 42 +++++++++++++++- .../shared/stores/send/TransactionStore.qml | 6 +++ ui/app/AppLayouts/Wallet/WalletUtils.qml | 16 +++++++ .../Wallet/controls/MaxSendButton.qml | 41 ++++++++++++++++ ui/app/AppLayouts/Wallet/controls/qmldir | 1 + .../Wallet/panels/SwapInputPanel.qml | 47 ++++++++---------- .../Wallet/popups/swap/SwapModalAdaptor.qml | 4 ++ ui/imports/shared/popups/send/SendModal.qml | 48 ++++++------------- .../shared/popups/send/views/AmountToSend.qml | 5 -- 10 files changed, 143 insertions(+), 69 deletions(-) create mode 100644 ui/app/AppLayouts/Wallet/controls/MaxSendButton.qml diff --git a/storybook/pages/SwapInputPanelPage.qml b/storybook/pages/SwapInputPanelPage.qml index 9707cfab0b..e44d19532c 100644 --- a/storybook/pages/SwapInputPanelPage.qml +++ b/storybook/pages/SwapInputPanelPage.qml @@ -40,7 +40,7 @@ SplitView { swapStore: SwapStore { readonly property var accounts: WalletAccountsModel {} readonly property var flatNetworks: NetworksModel.flatNetworks - readonly property bool areTestNetworksEnabled: false + readonly property bool areTestNetworksEnabled: true } walletAssetsStore: WalletAssetsStore { id: thisWalletAssetStore diff --git a/storybook/qmlTests/tests/tst_SwapInputPanel.qml b/storybook/qmlTests/tests/tst_SwapInputPanel.qml index 2c6193a7e8..d88f12be17 100644 --- a/storybook/qmlTests/tests/tst_SwapInputPanel.qml +++ b/storybook/qmlTests/tests/tst_SwapInputPanel.qml @@ -26,7 +26,7 @@ Item { swapStore: SwapStore { readonly property var accounts: WalletAccountsModel {} readonly property var flatNetworks: NetworksModel.flatNetworks - readonly property bool areTestNetworksEnabled: false + readonly property bool areTestNetworksEnabled: true } walletAssetsStore: WalletAssetsStore { id: thisWalletAssetStore @@ -201,6 +201,46 @@ Item { verify(controlUnderTest.cryptoValueValid) } + // verify that when "fiatInputInteractive" mode is on, the Max send button text shows fiat currency symbol (e.g. "1.2 USD") + function test_maxButtonFiatCurrencySymbol() { + controlUnderTest = createTemporaryObject(componentUnderTest, root, {tokenKey: "ETH"}) + verify(!!controlUnderTest) + waitForRendering(controlUnderTest) + controlUnderTest.fiatInputInteractive = true + + const maxTagButton = findChild(controlUnderTest, "maxTagButton") + verify(!!maxTagButton) + waitForRendering(maxTagButton) + verify(maxTagButton.visible) + verify(!maxTagButton.text.endsWith("ETH")) + mouseClick(maxTagButton) + + const amountToSendInput = findChild(controlUnderTest, "amountToSendInput") + verify(!!amountToSendInput) + waitForRendering(amountToSendInput) + + const bottomItemText = findChild(amountToSendInput, "bottomItemText") + verify(!!bottomItemText) + verify(bottomItemText.visible) + mouseClick(bottomItemText) + waitForRendering(amountToSendInput) + + verify(maxTagButton.text.endsWith("USD")) + } + + // verify that in default mode, the Max send button text doesn't show the currency symbol for crypto (e.g. "1.2" for ETH) + function test_maxButtonNoCryptoCurrencySymbol() { + controlUnderTest = createTemporaryObject(componentUnderTest, root, {tokenKey: "ETH"}) + verify(!!controlUnderTest) + waitForRendering(controlUnderTest) + + const maxTagButton = findChild(controlUnderTest, "maxTagButton") + verify(!!maxTagButton) + waitForRendering(maxTagButton) + verify(maxTagButton.visible) + verify(!maxTagButton.text.endsWith("ETH")) + } + function test_clickingMaxButton() { controlUnderTest = createTemporaryObject(componentUnderTest, root, {tokenKey: "ETH"}) verify(!!controlUnderTest) diff --git a/storybook/stubs/shared/stores/send/TransactionStore.qml b/storybook/stubs/shared/stores/send/TransactionStore.qml index 0308538bed..15ebff8e11 100644 --- a/storybook/stubs/shared/stores/send/TransactionStore.qml +++ b/storybook/stubs/shared/stores/send/TransactionStore.qml @@ -211,6 +211,12 @@ QtObject { root.showUnPreferredChains = !root.showUnPreferredChains } + function setSelectedTokenIsOwnerToken(isOwnerToken) { + } + + function setSelectedTokenName(tokenName) { + } + property string amountToSend property bool suggestedRoutesCalled: false function suggestedRoutes(amount) { diff --git a/ui/app/AppLayouts/Wallet/WalletUtils.qml b/ui/app/AppLayouts/Wallet/WalletUtils.qml index 88587d2fe0..b83af36972 100644 --- a/ui/app/AppLayouts/Wallet/WalletUtils.qml +++ b/ui/app/AppLayouts/Wallet/WalletUtils.qml @@ -57,4 +57,20 @@ QtObject { } return hovered? WalletUtils.colorizedChainPrefix(chainShortNames) + Utils.richColorText(finalAddress, Theme.palette.directColor1) : chainShortNames + finalAddress } + + /** + Calculate max safe amount to be used when making a transaction + + This logic is here to make sure there is enough eth to pay for the gas. + Context, when making a transaction, whatever the type: swap/bridge/send, you need eth to pay for the gas. + + rationale: https://github.com/status-im/status-desktop/pull/14959#discussion_r1627110880 + */ + function calculateMaxSafeSendAmount(value, symbol) { + if (symbol !== Constants.ethToken) { + return value + } + + return value - Math.max(0.0001, Math.min(0.01, value * 0.1)) + } } diff --git a/ui/app/AppLayouts/Wallet/controls/MaxSendButton.qml b/ui/app/AppLayouts/Wallet/controls/MaxSendButton.qml new file mode 100644 index 0000000000..2779b123de --- /dev/null +++ b/ui/app/AppLayouts/Wallet/controls/MaxSendButton.qml @@ -0,0 +1,41 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +import StatusQ.Core 0.1 +import StatusQ.Controls 0.1 + +import AppLayouts.Wallet 1.0 + +import utils 1.0 + +StatusButton { + id: root + + required property double value + required property string symbol + required property bool valid + property var formatCurrencyAmount: (amount, symbol) => { return "FIXME" } + + readonly property double maxSafeValue: WalletUtils.calculateMaxSafeSendAmount(value, symbol) + readonly property string maxSafeValueAsString: maxSafeValue.toLocaleString(locale, 'f', -128) + + locale: LocaleUtils.userInputLocale + + QtObject { + id: d + + readonly property string maxInputBalanceFormatted: + root.formatCurrencyAmount(Math.trunc(root.maxSafeValue*100)/100, root.symbol) + } + + implicitHeight: 22 + + type: valid ? StatusBaseButton.Type.Normal : StatusBaseButton.Type.Danger + text: qsTr("Max. %1").arg(value === 0 ? locale.zeroDigit : d.maxInputBalanceFormatted) + + horizontalPadding: 8 + verticalPadding: 3 + radius: 20 + font.pixelSize: 12 + font.weight: Font.Normal +} diff --git a/ui/app/AppLayouts/Wallet/controls/qmldir b/ui/app/AppLayouts/Wallet/controls/qmldir index 4569dfa8c5..1c831f5384 100644 --- a/ui/app/AppLayouts/Wallet/controls/qmldir +++ b/ui/app/AppLayouts/Wallet/controls/qmldir @@ -10,6 +10,7 @@ ManageTokenMenuButton 1.0 ManageTokenMenuButton.qml ManageTokensCommunityTag 1.0 ManageTokensCommunityTag.qml ManageTokensDelegate 1.0 ManageTokensDelegate.qml ManageTokensGroupDelegate 1.0 ManageTokensGroupDelegate.qml +MaxSendButton 1.0 MaxSendButton.qml InformationTileAssetDetails 1.0 InformationTileAssetDetails.qml StatusNetworkListItemTag 1.0 StatusNetworkListItemTag.qml CollectibleBalanceTag 1.0 CollectibleBalanceTag.qml diff --git a/ui/app/AppLayouts/Wallet/panels/SwapInputPanel.qml b/ui/app/AppLayouts/Wallet/panels/SwapInputPanel.qml index c16454ed7a..e94d2d4f24 100644 --- a/ui/app/AppLayouts/Wallet/panels/SwapInputPanel.qml +++ b/ui/app/AppLayouts/Wallet/panels/SwapInputPanel.qml @@ -10,6 +10,8 @@ import StatusQ.Core 0.1 import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Core.Theme 0.1 +import AppLayouts.Wallet.controls 1.0 + import shared.popups.send.views 1.0 import shared.popups.send.panels 1.0 @@ -92,17 +94,6 @@ Control { readonly property double maxInputBalance: amountToSendInput.inputIsFiat ? maxFiatBalance : maxCryptoBalance readonly property string inputSymbol: amountToSendInput.inputIsFiat ? root.currencyStore.currentCurrency : !!d.selectedHolding && !!d.selectedHolding.symbol ? d.selectedHolding.symbol: "" - readonly property string maxInputBalanceFormatted: - root.currencyStore.formatCurrencyAmount(Math.trunc(prepareForMaxSend(d.maxInputBalance, d.inputSymbol)*100)/100, d.inputSymbol, {noSymbol: !amountToSendInput.inputIsFiat}) - - function prepareForMaxSend(value, symbol) { - if (symbol !== Constants.ethToken) { - return value - } - - return value - Math.max(0.0001, Math.min(0.01, value * 0.1)) - } - property string searchText } @@ -197,13 +188,15 @@ Control { interactive: true selectedHolding: d.selectedHolding fiatInputInteractive: root.fiatInputInteractive + input.input.edit.color: !input.valid ? Theme.palette.dangerColor1 : maxSendButton.hovered ? Theme.palette.baseColor1 + : Theme.palette.directColor1 multiplierIndex: d.isSelectedHoldingValidAsset && !!holdingSelector.selectedItem && !!holdingSelector.selectedItem.decimals ? holdingSelector.selectedItem.decimals : 0 maxInputBalance: (root.swapSide === SwapInputPanel.SwapSide.Receive || !d.isSelectedHoldingValidAsset) ? Number.POSITIVE_INFINITY - : d.prepareForMaxSend(d.maxInputBalance, d.inputSymbol) + : maxSendButton.maxSafeValue currentCurrency: root.currencyStore.currentCurrency formatCurrencyAmount: root.currencyStore.formatCurrencyAmount loading: root.loading @@ -247,26 +240,24 @@ Control { onSearchTextChanged: d.searchText = searchText } - Item { Layout.fillHeight: !itemTag.visible } + Item { Layout.fillHeight: !maxSendButton.visible } - StatusListItemTag { - id: itemTag - objectName: "maxTagButton" + MaxSendButton { + id: maxSendButton Layout.alignment: Qt.AlignRight Layout.maximumWidth: parent.width - Layout.preferredHeight: 22 + objectName: "maxTagButton" + + value: d.maxInputBalance + symbol: d.inputSymbol + valid: amountToSendInput.input.valid || !amountToSendInput.input.text + formatCurrencyAmount: (amount, symbol) => root.currencyStore.formatCurrencyAmount(amount, symbol, {noSymbol: !amountToSendInput.inputIsFiat}) + visible: d.isSelectedHoldingValidAsset && root.swapSide === SwapInputPanel.SwapSide.Pay - title: d.maxInputBalance > 0 ? qsTr("Max: %1").arg(d.maxInputBalanceFormatted) - : qsTr("No balances active") - tagClickable: true - closeButtonVisible: false - titleText.font.pixelSize: 12 - bgColor: amountToSendInput.input.valid || !amountToSendInput.input.text ? Theme.palette.primaryColor3 : Theme.palette.dangerColor2 - titleText.color: amountToSendInput.input.valid || !amountToSendInput.input.text ? Theme.palette.primaryColor1 : Theme.palette.dangerColor1 - onTagClicked: { - const max = d.prepareForMaxSend(d.maxInputBalance, d.inputSymbol) - if (max > 0) - amountToSendInput.input.text = max.toLocaleString(Qt.locale(), 'f', -128) + + onClicked: { + if (maxSafeValue) + amountToSendInput.input.text = maxSafeValueAsString else amountToSendInput.input.input.edit.clear() amountToSendInput.input.forceActiveFocus() diff --git a/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml b/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml index 1c071f7707..9628d6a828 100644 --- a/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml +++ b/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml @@ -181,6 +181,10 @@ QObject { roleName: "chainId" value: root.swapFormData.selectedNetworkChainId enabled: root.swapFormData.selectedNetworkChainId !== -1 + }, + ValueFilter { + roleName: "isTest" + value: root.swapStore.areTestNetworksEnabled }/*, // TODO enable once AccountsModalHeader is reworked!! ValueFilter { diff --git a/ui/imports/shared/popups/send/SendModal.qml b/ui/imports/shared/popups/send/SendModal.qml index aabce55e3f..09a23ac311 100644 --- a/ui/imports/shared/popups/send/SendModal.qml +++ b/ui/imports/shared/popups/send/SendModal.qml @@ -17,6 +17,8 @@ import StatusQ.Core.Theme 0.1 import StatusQ.Core.Utils 0.1 import StatusQ.Popups.Dialog 0.1 +import AppLayouts.Wallet.controls 1.0 + import "./panels" import "./controls" import "./views" @@ -141,14 +143,6 @@ StatusDialog { recalculateRoutesAndFees() } - - function prepareForMaxSend(value, symbol) { - if(symbol !== "ETH") { - return value - } - - return value - Math.max(0.0001, Math.min(0.01, value * 0.1)) - } } bottomPadding: 16 @@ -277,36 +271,22 @@ StatusDialog { } } - StatusListItemTag { + MaxSendButton { Layout.maximumWidth: 300 Layout.alignment: Qt.AlignVCenter | Qt.AlignRight - Layout.preferredHeight: 22 visible: d.isSelectedHoldingValidAsset || d.isHoveredHoldingValidAsset && !d.isCollectiblesTransfer - title: { - if(d.isHoveredHoldingValidAsset && !!d.hoveredHolding.symbol) { - const input = amountToSendInput.inputIsFiat ? d.hoveredHolding.currentCurrencyBalance : d.hoveredHolding.currentBalance - const max = d.prepareForMaxSend(input, d.hoveredHolding.symbol) - if (max <= 0) - return qsTr("No balances active") - const balance = d.currencyStore.formatCurrencyAmount(max, amountToSendInput.inputIsFiat ? amountToSendInput.currentCurrency - : d.selectedHolding.symbol) - return qsTr("Max: %1").arg(balance.toString()) - } - const max = d.prepareForMaxSend(d.maxInputBalance, d.inputSymbol) - if (max <= 0) - return qsTr("No balances active") - const balance = d.currencyStore.formatCurrencyAmount(max, d.inputSymbol) - return qsTr("Max: %1").arg(balance.toString()) - } - tagClickable: true - closeButtonVisible: false - titleText.font.pixelSize: 12 - bgColor: amountToSendInput.input.valid || !amountToSendInput.input.text ? Theme.palette.primaryColor3 : Theme.palette.dangerColor2 - titleText.color: amountToSendInput.input.valid || !amountToSendInput.input.text ? Theme.palette.primaryColor1 : Theme.palette.dangerColor1 - onTagClicked: { - const max = d.prepareForMaxSend(d.maxInputBalance, d.inputSymbol) - amountToSendInput.input.text = d.currencyStore.formatCurrencyAmount(max, d.inputSymbol, {noSymbol: true, rawAmount: true}, LocaleUtils.userInputLocale) + value: d.maxInputBalance + symbol: d.inputSymbol + valid: amountToSendInput.input.valid || !amountToSendInput.input.text + formatCurrencyAmount: (amount, symbol) => d.currencyStore.formatCurrencyAmount(amount, symbol, {noSymbol: !amountToSendInput.inputIsFiat}) + + onClicked: { + if (maxSafeValue > 0) + amountToSendInput.input.text = maxSafeValueAsString + else + amountToSendInput.input.input.edit.clear() + amountToSendInput.input.forceActiveFocus() } } } diff --git a/ui/imports/shared/popups/send/views/AmountToSend.qml b/ui/imports/shared/popups/send/views/AmountToSend.qml index 56e31f049a..2d5fd8d1cf 100644 --- a/ui/imports/shared/popups/send/views/AmountToSend.qml +++ b/ui/imports/shared/popups/send/views/AmountToSend.qml @@ -5,7 +5,6 @@ import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Controls 0.1 import StatusQ.Core.Utils 0.1 as SQUtils -import StatusQ.Controls 0.1 import StatusQ.Components 0.1 import StatusQ.Controls.Validators 0.1 @@ -128,10 +127,6 @@ ColumnLayout { Layout.fillWidth: true id: topItem - property double topAmountToSend: !inputIsFiat ? d.cryptoValueToSend - : d.fiatValueToSend - property string topAmountSymbol: !inputIsFiat ? d.selectedSymbol - : root.currentCurrency AmountInputWithCursor { id: topAmountToSendInput Layout.fillWidth: true