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
This commit is contained in:
Lukáš Tinkl 2024-06-10 12:37:39 +02:00 committed by Lukáš Tinkl
parent 4d080e12aa
commit 46b81b30a6
10 changed files with 143 additions and 69 deletions

View File

@ -40,7 +40,7 @@ SplitView {
swapStore: SwapStore { swapStore: SwapStore {
readonly property var accounts: WalletAccountsModel {} readonly property var accounts: WalletAccountsModel {}
readonly property var flatNetworks: NetworksModel.flatNetworks readonly property var flatNetworks: NetworksModel.flatNetworks
readonly property bool areTestNetworksEnabled: false readonly property bool areTestNetworksEnabled: true
} }
walletAssetsStore: WalletAssetsStore { walletAssetsStore: WalletAssetsStore {
id: thisWalletAssetStore id: thisWalletAssetStore

View File

@ -26,7 +26,7 @@ Item {
swapStore: SwapStore { swapStore: SwapStore {
readonly property var accounts: WalletAccountsModel {} readonly property var accounts: WalletAccountsModel {}
readonly property var flatNetworks: NetworksModel.flatNetworks readonly property var flatNetworks: NetworksModel.flatNetworks
readonly property bool areTestNetworksEnabled: false readonly property bool areTestNetworksEnabled: true
} }
walletAssetsStore: WalletAssetsStore { walletAssetsStore: WalletAssetsStore {
id: thisWalletAssetStore id: thisWalletAssetStore
@ -201,6 +201,46 @@ Item {
verify(controlUnderTest.cryptoValueValid) 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() { function test_clickingMaxButton() {
controlUnderTest = createTemporaryObject(componentUnderTest, root, {tokenKey: "ETH"}) controlUnderTest = createTemporaryObject(componentUnderTest, root, {tokenKey: "ETH"})
verify(!!controlUnderTest) verify(!!controlUnderTest)

View File

@ -211,6 +211,12 @@ QtObject {
root.showUnPreferredChains = !root.showUnPreferredChains root.showUnPreferredChains = !root.showUnPreferredChains
} }
function setSelectedTokenIsOwnerToken(isOwnerToken) {
}
function setSelectedTokenName(tokenName) {
}
property string amountToSend property string amountToSend
property bool suggestedRoutesCalled: false property bool suggestedRoutesCalled: false
function suggestedRoutes(amount) { function suggestedRoutes(amount) {

View File

@ -57,4 +57,20 @@ QtObject {
} }
return hovered? WalletUtils.colorizedChainPrefix(chainShortNames) + Utils.richColorText(finalAddress, Theme.palette.directColor1) : chainShortNames + finalAddress 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))
}
} }

View File

@ -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
}

View File

@ -10,6 +10,7 @@ ManageTokenMenuButton 1.0 ManageTokenMenuButton.qml
ManageTokensCommunityTag 1.0 ManageTokensCommunityTag.qml ManageTokensCommunityTag 1.0 ManageTokensCommunityTag.qml
ManageTokensDelegate 1.0 ManageTokensDelegate.qml ManageTokensDelegate 1.0 ManageTokensDelegate.qml
ManageTokensGroupDelegate 1.0 ManageTokensGroupDelegate.qml ManageTokensGroupDelegate 1.0 ManageTokensGroupDelegate.qml
MaxSendButton 1.0 MaxSendButton.qml
InformationTileAssetDetails 1.0 InformationTileAssetDetails.qml InformationTileAssetDetails 1.0 InformationTileAssetDetails.qml
StatusNetworkListItemTag 1.0 StatusNetworkListItemTag.qml StatusNetworkListItemTag 1.0 StatusNetworkListItemTag.qml
CollectibleBalanceTag 1.0 CollectibleBalanceTag.qml CollectibleBalanceTag 1.0 CollectibleBalanceTag.qml

View File

@ -10,6 +10,8 @@ import StatusQ.Core 0.1
import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Core.Utils 0.1 as SQUtils
import StatusQ.Core.Theme 0.1 import StatusQ.Core.Theme 0.1
import AppLayouts.Wallet.controls 1.0
import shared.popups.send.views 1.0 import shared.popups.send.views 1.0
import shared.popups.send.panels 1.0 import shared.popups.send.panels 1.0
@ -92,17 +94,6 @@ Control {
readonly property double maxInputBalance: amountToSendInput.inputIsFiat ? maxFiatBalance : maxCryptoBalance readonly property double maxInputBalance: amountToSendInput.inputIsFiat ? maxFiatBalance : maxCryptoBalance
readonly property string inputSymbol: amountToSendInput.inputIsFiat ? root.currencyStore.currentCurrency : readonly property string inputSymbol: amountToSendInput.inputIsFiat ? root.currencyStore.currentCurrency :
!!d.selectedHolding && !!d.selectedHolding.symbol ? d.selectedHolding.symbol: "" !!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 property string searchText
} }
@ -197,13 +188,15 @@ Control {
interactive: true interactive: true
selectedHolding: d.selectedHolding selectedHolding: d.selectedHolding
fiatInputInteractive: root.fiatInputInteractive 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 multiplierIndex: d.isSelectedHoldingValidAsset && !!holdingSelector.selectedItem && !!holdingSelector.selectedItem.decimals
? holdingSelector.selectedItem.decimals ? holdingSelector.selectedItem.decimals
: 0 : 0
maxInputBalance: (root.swapSide === SwapInputPanel.SwapSide.Receive || !d.isSelectedHoldingValidAsset) ? Number.POSITIVE_INFINITY maxInputBalance: (root.swapSide === SwapInputPanel.SwapSide.Receive || !d.isSelectedHoldingValidAsset) ? Number.POSITIVE_INFINITY
: d.prepareForMaxSend(d.maxInputBalance, d.inputSymbol) : maxSendButton.maxSafeValue
currentCurrency: root.currencyStore.currentCurrency currentCurrency: root.currencyStore.currentCurrency
formatCurrencyAmount: root.currencyStore.formatCurrencyAmount formatCurrencyAmount: root.currencyStore.formatCurrencyAmount
loading: root.loading loading: root.loading
@ -247,26 +240,24 @@ Control {
onSearchTextChanged: d.searchText = searchText onSearchTextChanged: d.searchText = searchText
} }
Item { Layout.fillHeight: !itemTag.visible } Item { Layout.fillHeight: !maxSendButton.visible }
StatusListItemTag { MaxSendButton {
id: itemTag id: maxSendButton
objectName: "maxTagButton"
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
Layout.maximumWidth: parent.width 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 visible: d.isSelectedHoldingValidAsset && root.swapSide === SwapInputPanel.SwapSide.Pay
title: d.maxInputBalance > 0 ? qsTr("Max: %1").arg(d.maxInputBalanceFormatted)
: qsTr("No balances active") onClicked: {
tagClickable: true if (maxSafeValue)
closeButtonVisible: false amountToSendInput.input.text = maxSafeValueAsString
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)
else else
amountToSendInput.input.input.edit.clear() amountToSendInput.input.input.edit.clear()
amountToSendInput.input.forceActiveFocus() amountToSendInput.input.forceActiveFocus()

View File

@ -181,6 +181,10 @@ QObject {
roleName: "chainId" roleName: "chainId"
value: root.swapFormData.selectedNetworkChainId value: root.swapFormData.selectedNetworkChainId
enabled: root.swapFormData.selectedNetworkChainId !== -1 enabled: root.swapFormData.selectedNetworkChainId !== -1
},
ValueFilter {
roleName: "isTest"
value: root.swapStore.areTestNetworksEnabled
}/*, }/*,
// TODO enable once AccountsModalHeader is reworked!! // TODO enable once AccountsModalHeader is reworked!!
ValueFilter { ValueFilter {

View File

@ -17,6 +17,8 @@ import StatusQ.Core.Theme 0.1
import StatusQ.Core.Utils 0.1 import StatusQ.Core.Utils 0.1
import StatusQ.Popups.Dialog 0.1 import StatusQ.Popups.Dialog 0.1
import AppLayouts.Wallet.controls 1.0
import "./panels" import "./panels"
import "./controls" import "./controls"
import "./views" import "./views"
@ -141,14 +143,6 @@ StatusDialog {
recalculateRoutesAndFees() 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 bottomPadding: 16
@ -277,36 +271,22 @@ StatusDialog {
} }
} }
StatusListItemTag { MaxSendButton {
Layout.maximumWidth: 300 Layout.maximumWidth: 300
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
Layout.preferredHeight: 22
visible: d.isSelectedHoldingValidAsset || d.isHoveredHoldingValidAsset && !d.isCollectiblesTransfer 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) value: d.maxInputBalance
return qsTr("Max: %1").arg(balance.toString()) symbol: d.inputSymbol
} valid: amountToSendInput.input.valid || !amountToSendInput.input.text
tagClickable: true formatCurrencyAmount: (amount, symbol) => d.currencyStore.formatCurrencyAmount(amount, symbol, {noSymbol: !amountToSendInput.inputIsFiat})
closeButtonVisible: false
titleText.font.pixelSize: 12 onClicked: {
bgColor: amountToSendInput.input.valid || !amountToSendInput.input.text ? Theme.palette.primaryColor3 : Theme.palette.dangerColor2 if (maxSafeValue > 0)
titleText.color: amountToSendInput.input.valid || !amountToSendInput.input.text ? Theme.palette.primaryColor1 : Theme.palette.dangerColor1 amountToSendInput.input.text = maxSafeValueAsString
onTagClicked: { else
const max = d.prepareForMaxSend(d.maxInputBalance, d.inputSymbol) amountToSendInput.input.input.edit.clear()
amountToSendInput.input.text = d.currencyStore.formatCurrencyAmount(max, d.inputSymbol, {noSymbol: true, rawAmount: true}, LocaleUtils.userInputLocale) amountToSendInput.input.forceActiveFocus()
} }
} }
} }

View File

@ -5,7 +5,6 @@ import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1 import StatusQ.Core.Theme 0.1
import StatusQ.Controls 0.1 import StatusQ.Controls 0.1
import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Core.Utils 0.1 as SQUtils
import StatusQ.Controls 0.1
import StatusQ.Components 0.1 import StatusQ.Components 0.1
import StatusQ.Controls.Validators 0.1 import StatusQ.Controls.Validators 0.1
@ -128,10 +127,6 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
id: topItem id: topItem
property double topAmountToSend: !inputIsFiat ? d.cryptoValueToSend
: d.fiatValueToSend
property string topAmountSymbol: !inputIsFiat ? d.selectedSymbol
: root.currentCurrency
AmountInputWithCursor { AmountInputWithCursor {
id: topAmountToSendInput id: topAmountToSendInput
Layout.fillWidth: true Layout.fillWidth: true