diff --git a/storybook/pages/CollectiblesSelectionAdaptorPage.qml b/storybook/pages/CollectiblesSelectionAdaptorPage.qml index c388c932d0c..9ce088be3ff 100644 --- a/storybook/pages/CollectiblesSelectionAdaptorPage.qml +++ b/storybook/pages/CollectiblesSelectionAdaptorPage.qml @@ -10,6 +10,7 @@ import AppLayouts.Wallet.controls 1.0 import AppLayouts.Wallet.adaptors 1.0 import utils 1.0 +import Models 1.0 import Storybook 1.0 import SortFilterProxyModel 0.2 @@ -24,6 +25,8 @@ Pane { // collection 2 { tokenId: "id_3", + symbol: "abc", + chainId: NetworksModel.mainnetChainId, name: "Multi-sequencer Test NFT 1", contractAddress: "contract_2", collectionName: "Multi-sequencer Test NFT", @@ -43,6 +46,8 @@ Pane { }, { tokenId: "id_4", + symbol: "def", + chainId: NetworksModel.mainnetChainId, name: "Multi-sequencer Test NFT 2", contractAddress: "contract_2", collectionName: "Multi-sequencer Test NFT", @@ -62,6 +67,8 @@ Pane { }, { tokenId: "id_5", + symbol: "ghi", + chainId: NetworksModel.mainnetChainId, name: "Multi-sequencer Test NFT 3", contractAddress: "contract_2", collectionName: "Multi-sequencer Test NFT", @@ -82,6 +89,8 @@ Pane { // collection 1 { tokenId: "id_1", + symbol: "jkl", + chainId: NetworksModel.mainnetChainId, name: "Genesis", contractAddress: "contract_1", collectionName: "ERC-1155 Faucet", @@ -106,6 +115,8 @@ Pane { }, { tokenId: "id_2", + symbol: "mno", + chainId: NetworksModel.mainnetChainId, name: "QAERC1155", contractAddress: "contract_1", collectionName: "ERC-1155 Faucet", @@ -126,6 +137,8 @@ Pane { // collection 3, community token { tokenId: "id_6", + symbol: "pqr", + chainId: NetworksModel.optChainId, name: "My Token", contractAddress: "contract_3", collectionName: "My Token", @@ -145,6 +158,8 @@ Pane { }, { tokenId: "id_7", + symbol: "stu", + chainId: NetworksModel.optChainId, name: "My Token", contractAddress: "contract_3", collectionName: "My Token", @@ -164,6 +179,8 @@ Pane { }, { tokenId: "id_8", + symbol: "vwx", + chainId: NetworksModel.optChainId, name: "My Token", contractAddress: "contract_3", collectionName: "My Token", @@ -183,6 +200,8 @@ Pane { }, { tokenId: "id_9", + symbol: "yz1", + chainId: NetworksModel.optChainId, name: "My Other Token", contractAddress: "contract_4", collectionName: "My Other Token", @@ -202,6 +221,8 @@ Pane { }, { tokenId: "id_10", + symbol: "234", + chainId: NetworksModel.arbChainId, name: "My Community 2 Token", contractAddress: "contract_5", collectionName: "My Community 2 Token", @@ -221,6 +242,8 @@ Pane { }, { tokenId: "id_11", + symbol: "567", + chainId: NetworksModel.arbChainId, name: "My Community 2 Token", contractAddress: "contract_5", collectionName: "My Community 2 Token", @@ -240,6 +263,8 @@ Pane { }, { tokenId: "id_11", + symbol: "8910", + chainId: NetworksModel.arbChainId, name: "My Community 2 Token", contractAddress: "contract_5", collectionName: "My Community 2 Token", @@ -259,6 +284,8 @@ Pane { }, { tokenId: "id_12", + symbol: "111213", + chainId: NetworksModel.arbChainId, name: "My Community 2 Token", contractAddress: "contract_5", collectionName: "My Community 2 Token", @@ -278,6 +305,8 @@ Pane { }, { tokenId: "id_13", + symbol: "141516", + chainId: NetworksModel.arbChainId, name: "My Community 2 Token", contractAddress: "contract_5", collectionName: "My Community 2 Token", @@ -312,6 +341,8 @@ Pane { CollectiblesSelectionAdaptor { id: adaptor + networksModel: NetworksModel.flatNetworks + enabledChainIds: [networksCombobox.currentValue] collectiblesModel: listModel accountKey: accountsSelector.selection } @@ -333,6 +364,18 @@ Pane { } } + RowLayout { + Label { text: "Enabled Networks:" } + + ComboBox { + id: networksCombobox + model: NetworksModel.flatNetworks + textRole: "chainName" + valueRole: "chainId" + currentIndex: 0 + } + } + RowLayout { GenericListView { label: "Input model" diff --git a/storybook/pages/SendModalFooterPage.qml b/storybook/pages/SendModalFooterPage.qml new file mode 100644 index 00000000000..906bcd4cba2 --- /dev/null +++ b/storybook/pages/SendModalFooterPage.qml @@ -0,0 +1,62 @@ +import QtQuick 2.14 +import QtQuick.Controls 2.14 + +import StatusQ.Core.Theme 0.1 + +import Storybook 1.0 + +import AppLayouts.Wallet.views 1.0 + +SplitView { + orientation: Qt.Vertical + Logs { id: logs } + + Rectangle { + SplitView.fillHeight: true + SplitView.fillWidth: true + color: Theme.palette.indirectColor1 + + SendModalFooter { + id: footer + anchors.centerIn: parent + width: 595 + + loading: loadingCheckbox.checked + error: errorCheckbox.checked + + onReviewSendClicked: logs.logEvent("review send clicked") + } + } + + LogsAndControlsPanel { + id: logsAndControlsPanel + + SplitView.minimumHeight: 100 + SplitView.preferredHeight: 200 + + logsView.logText: logs.logText + + Column { + CheckBox { + id: loadingCheckbox + text: "loading" + } + + CheckBox { + id: errorCheckbox + text: "error" + } + + Button { + text: "set fees values" + onClicked: { + loadingCheckbox.checked = false + footer.estimatedTime = "~60s" + footer.estimatedFees = "1.45 EUR" + } + } + } + } +} + +// category: Views diff --git a/storybook/pages/SimpleSendModalPage.qml b/storybook/pages/SimpleSendModalPage.qml index 04e0ae49810..99ae36858b2 100644 --- a/storybook/pages/SimpleSendModalPage.qml +++ b/storybook/pages/SimpleSendModalPage.qml @@ -1,18 +1,71 @@ import QtQuick 2.15 -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 + +import SortFilterProxyModel 0.2 + +import StatusQ 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Backpressure 0.1 +import Models 1.0 import Storybook 1.0 import AppLayouts.Wallet.popups.simpleSend 1.0 +import AppLayouts.Wallet.stores 1.0 +import AppLayouts.Wallet.adaptors 1.0 + +import utils 1.0 SplitView { id: root orientation: Qt.Horizontal - function launchPopup() { - simpleSend.createObject(root) + QtObject { + id: d + + readonly property SortFilterProxyModel filteredNetworksModel: SortFilterProxyModel { + sourceModel: NetworksModel.flatNetworks + filters: ValueFilter { roleName: "isTest"; value: testNetworksCheckbox.checked } + } + + readonly property WalletAssetsStore walletAssetStore: WalletAssetsStore { + assetsWithFilteredBalances: groupedAccountsAssetsModel + } + + readonly property var walletAccountsModel: WalletAccountsModel{} + + function getCurrencyAmount(amount, symbol) { + return ({ + amount: amount, + symbol: symbol ? symbol.toUpperCase() : root.currentCurrency, + displayDecimals: 2, + stripTrailingZeroes: false + }) + } + + readonly property var savedAddressesModel: ListModel { + Component.onCompleted: { + for (let i = 0; i < 10; i++) + append({ + name: "some saved addr name " + i, + ens: [], + address: "0x2B748A02e06B159C7C3E98F5064577B96E55A7b4", + }) + append({ + name: "some saved ENS name ", + ens: ["me@status.eth"], + address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc4", + }) + } + } + + property var setFees: Backpressure.debounce(root, 1500, function () { + simpleSend.estimatedTime = "~60s" + simpleSend.estimatedFiatFees = "1.45 EUR" + simpleSend.estimatedCryptoFees = "0.0007 ETH" + }) } PopupBackground { @@ -27,25 +80,555 @@ SplitView { text: "Reopen" enabled: !simpleSend.visible - onClicked: launchPopup() + onClicked: simpleSend.open() } - Component.onCompleted: launchPopup() + Component.onCompleted: simpleSend.open() } - Component { + SimpleSendModal { id: simpleSend - SimpleSendModal { - visible: true - modal: false - closePolicy: Popup.NoAutoClose + + visible: true + modal: false + closePolicy: Popup.CloseOnEscape + + interactive: interactiveCheckbox.checked + + accountsModel: d.walletAccountsModel + assetsModel: assetsSelectorViewAdaptor.outputAssetsModel + collectiblesModel: collectiblesSelectionAdaptor.model + networksModel: d.filteredNetworksModel + + savedAddressesModel: d.savedAddressesModel + recentRecipientsModel: WalletTransactionsModel{} + + currentCurrency: "USD" + fnFormatCurrencyAmount: function(amount, symbol, options = null, locale = null) { + if (isNaN(amount)) { + return "N/A" + } + var currencyAmount = d.getCurrencyAmount(amount, symbol) + return LocaleUtils.currencyAmountToLocaleString(currencyAmount, options, locale) + } + + fnResolveENS: Backpressure.debounce(root, 500, function (ensName, uuid) { + if (!!ensName && ensName.endsWith(".eth")) { + // return some valid address + simpleSend.ensNameResolved(ensName, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc4", uuid) + } else { + simpleSend.ensNameResolved(ensName, "", uuid) // invalid + } + }) + + onFormChanged: { + estimatedCryptoFees = "" + estimatedFiatFees = "" + estimatedTime = "" + if(formCorrectlyFilled) { + console.log("Fetch fees...") + d.setFees() + } + } + + onReviewSendClicked: console.log("Review send clicked") + + Binding on selectedAccountAddress { + value: accountsCombobox.currentValue ?? "" + } + Binding on selectedChainId { + value: networksCombobox.currentValue ?? 0 + } + Binding on selectedTokenKey { + value: tokensCombobox.currentValue ?? "" + } + } + + TokenSelectorViewAdaptor { + id: assetsSelectorViewAdaptor + + assetsModel: d.walletAssetStore.groupedAccountAssetsModel + flatNetworksModel: NetworksModel.flatNetworks + + currentCurrency: "USD" + showCommunityAssets: true + + accountAddress: simpleSend.selectedAccountAddress + enabledChainIds: [simpleSend.selectedChainId] + } + + CollectiblesSelectionAdaptor { + id: collectiblesSelectionAdaptor + + accountKey: simpleSend.selectedAccountAddress + enabledChainIds: [simpleSend.selectedChainId] + + networksModel: d.filteredNetworksModel + collectiblesModel: collectiblesBySymbolModel + } + + ListModel { + id: collectiblesBySymbolModel + + readonly property var data: [ + // collection 2 + { + tokenId: "id_3", + symbol: "abc", + chainId: NetworksModel.mainnetChainId, + name: "Multi-seq NFT 1", + contractAddress: "contract_2", + collectionName: "Multi-sequencer Test NFT", + collectionUid: "collection_2", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059810 + } + ], + imageUrl: Constants.tokenIcon("ETH", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_4", + symbol: "def", + chainId: NetworksModel.mainnetChainId, + name: "Multi-seq NFT 2", + contractAddress: "contract_2", + collectionName: "Multi-sequencer Test NFT", + collectionUid: "collection_2", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059811 + } + ], + imageUrl: Constants.tokenIcon("ETH", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_5", + symbol: "ghi", + chainId: NetworksModel.mainnetChainId, + name: "Multi-seq NFT 3", + contractAddress: "contract_2", + collectionName: "Multi-sequencer Test NFT", + collectionUid: "collection_2", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059899 + } + ], + imageUrl: Constants.tokenIcon("ETH", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + tokenType: Constants.TokenType.ERC721 + }, + // collection 1 + { + tokenId: "id_1", + symbol: "jkl", + chainId: NetworksModel.mainnetChainId, + name: "Genesis", + contractAddress: "contract_1", + collectionName: "ERC-1155 Faucet", + collectionUid: "collection_1", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 23, + txTimestamp: 1714059862 + }, + { + accountAddress: d.walletAccountsModel.accountAddress2, + balance: 29, + txTimestamp: 1714054862 + } + ], + imageUrl: Constants.tokenIcon("DAI", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + tokenType: Constants.TokenType.ERC1155 + }, + { + tokenId: "id_2", + symbol: "mno", + chainId: NetworksModel.mainnetChainId, + name: "QAERC1155", + contractAddress: "contract_1", + collectionName: "ERC-1155 Faucet", + collectionUid: "collection_1", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 500, + txTimestamp: 1714059864 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + tokenType: Constants.TokenType.ERC1155 + }, + // collection 3, community token + { + tokenId: "id_6", + symbol: "pqr", + chainId: NetworksModel.optChainId, + name: "My Token", + contractAddress: "contract_3", + collectionName: "My Token", + collectionUid: "collection_3", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059899 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_1", + communityName: "My community", + communityImage: Constants.tokenIcon("KIN", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_7", + symbol: "stu", + chainId: NetworksModel.optChainId, + name: "My Token", + contractAddress: "contract_3", + collectionName: "My Token", + collectionUid: "collection_3", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059899 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_1", + communityName: "My community", + communityImage: Constants.tokenIcon("KIN", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_8", + symbol: "vwx", + chainId: NetworksModel.optChainId, + name: "My Token", + contractAddress: "contract_3", + collectionName: "My Token", + collectionUid: "collection_3", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress2, + balance: 1, + txTimestamp: 1714059999 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_1", + communityName: "My community", + communityImage: Constants.tokenIcon("KIN", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_9", + symbol: "yz1", + chainId: NetworksModel.optChainId, + name: "My Other Token", + contractAddress: "contract_4", + collectionName: "My Other Token", + collectionUid: "collection_4", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059991 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_1", + communityName: "My community", + communityImage: Constants.tokenIcon("KIN", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_10", + symbol: "234", + chainId: NetworksModel.arbChainId, + name: "My Community 2 Token", + contractAddress: "contract_5", + collectionName: "My Community 2 Token", + collectionUid: "collection_5", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059777 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_2", + communityName: "My community 2", + communityImage: Constants.tokenIcon("ICOS", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_11", + symbol: "567", + chainId: NetworksModel.arbChainId, + name: "My Community 2 Token", + contractAddress: "contract_5", + collectionName: "My Community 2 Token", + collectionUid: "collection_5", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress1, + balance: 1, + txTimestamp: 1714059778 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_2", + communityName: "My community 2", + communityImage: Constants.tokenIcon("ICOS", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_11", + symbol: "8910", + chainId: NetworksModel.arbChainId, + name: "My Community 2 Token", + contractAddress: "contract_5", + collectionName: "My Community 2 Token", + collectionUid: "collection_5", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress2, + balance: 1, + txTimestamp: 1714059779 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_2", + communityName: "My community 2", + communityImage: Constants.tokenIcon("ICOS", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_12", + symbol: "111213", + chainId: NetworksModel.arbChainId, + name: "My Community 2 Token", + contractAddress: "contract_5", + collectionName: "My Community 2 Token", + collectionUid: "collection_5", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress3, + balance: 1, + txTimestamp: 1714059779 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_2", + communityName: "My community 2", + communityImage: Constants.tokenIcon("ICOS", false), + tokenType: Constants.TokenType.ERC721 + }, + { + tokenId: "id_13", + symbol: "141516", + chainId: NetworksModel.arbChainId, + name: "My Community 2 Token", + contractAddress: "contract_5", + collectionName: "My Community 2 Token", + collectionUid: "collection_5", + ownership: [ + { + accountAddress: d.walletAccountsModel.accountAddress3, + balance: 1, + txTimestamp: 1714059788 + } + ], + imageUrl: Constants.tokenIcon("ZRX", false), + mediaUrl: Qt.resolvedUrl(""), + communityId: "community_2", + communityName: "My community 2", + communityImage: Constants.tokenIcon("ICOS", false), + tokenType: Constants.TokenType.ERC721 + } + ] + + Component.onCompleted: { + append(data) } } - LogsAndControlsPanel { + Pane { SplitView.minimumHeight: 100 - SplitView.preferredHeight: 100 + SplitView.minimumWidth: 300 + SplitView.maximumWidth: 380 + + ColumnLayout { + spacing: 20 + + CheckBox { + id: interactiveCheckbox + text: "Is interactive" + checked: true + } + + Text { + text: "Select an accounts" + } + ComboBox { + id: accountsCombobox + model: SortFilterProxyModel { + sourceModel: d.walletAccountsModel + filters: ValueFilter { + roleName: "walletType" + value: Constants.watchWalletType + inverted: true + } + } + textRole: "name" + valueRole: "address" + currentIndex: 0 + } + + CheckBox { + id: testNetworksCheckbox + text: "are test networks enabled" + } + Text { + text: "Select a network" + } + ComboBox { + id: networksCombobox + model: d.filteredNetworksModel + textRole: "chainName" + valueRole: "chainId" + currentIndex: 0 + } + + Text { + text: "Select a token" + } + ComboBox { + id: tokensCombobox + model: ConcatModel { + sources: [ + SourceModel { + model: assetsSelectorViewAdaptor.outputAssetsModel + markerRoleValue: "first_model" + }, + SourceModel { + model: collectiblesKeyModel + markerRoleValue: "second_model" + } + ] + markerRoleName: "which_model" + expectedRoles: ["tokensKey", "name"] + } + textRole: "name" + valueRole: "tokensKey" + } + + RowLayout { + Layout.fillWidth: true + TextField { + id: amountInput + Layout.preferredWidth: 200 + Layout.preferredHeight: 50 + validator: RegularExpressionValidator { + regularExpression: /^\d*\.?\d*$/ + } + } + Button { + text: "update in modal" + onClicked: simpleSend.selectedAmount = amountInput.text + } + } + Text { + text: "amount selected in base unit: " + simpleSend.selectedAmountInBaseUnit + } + + Text { + text: "Select a recipient" + } + RowLayout { + Layout.fillWidth: true + TextField { + id: recipientInput + Layout.preferredWidth: 200 + Layout.preferredHeight: 50 + } + Button { + text: "update in modal" + onClicked: simpleSend.selectedRecipientAddress = recipientInput.text + } + } + + Text { + text: "account selected is: \n" + + simpleSend.selectedAccountAddress + } + Text { + text: "network selected is: " + simpleSend.selectedChainId + } + Text { + text: "token selected is: " + simpleSend.selectedTokenKey + } + Text { + text: "amount entered is: " + simpleSend.selectedAmount + } + Text { + text: "selected recipient is: \n" + simpleSend.selectedRecipientAddress + } + + RolesRenamingModel { + id: collectiblesKeyModel + sourceModel: collectiblesSelectionAdaptor.model + + mapping: [ + RoleRename { + from: "symbol" + to: "tokensKey" + } + ] + } + } } } diff --git a/storybook/pages/SimpleTransactionsFeesPage.qml b/storybook/pages/SimpleTransactionsFeesPage.qml new file mode 100644 index 00000000000..1400cab01eb --- /dev/null +++ b/storybook/pages/SimpleTransactionsFeesPage.qml @@ -0,0 +1,50 @@ +import QtQuick 2.14 +import QtQuick.Controls 2.14 +import QtQuick.Layouts 1.14 + +import StatusQ.Core.Theme 0.1 + +import Storybook 1.0 + +import AppLayouts.Wallet.panels 1.0 + +SplitView { + orientation: Qt.Vertical + + Rectangle { + SplitView.fillHeight: true + SplitView.fillWidth: true + color: Theme.palette.baseColor3 + + SimpleTransactionsFees { + anchors.centerIn: parent + width: 400 + + cryptoFees: qsTr("0.0007 ETH") + fiatFees: qsTr("1.45 EUR") + loading: loadingCheckbox.checked + error: errorCheckbox.checked + } + } + + Pane { + id: logsAndControlsPanel + + SplitView.minimumHeight: 100 + SplitView.preferredHeight: 200 + + ColumnLayout { + CheckBox { + id: loadingCheckbox + text: "loading" + } + + CheckBox { + id: errorCheckbox + text: "error" + } + } + } +} + +// category: Views diff --git a/storybook/pages/StatusDialogPage.qml b/storybook/pages/StatusDialogPage.qml index fafe9dc9292..245cd41312f 100644 --- a/storybook/pages/StatusDialogPage.qml +++ b/storybook/pages/StatusDialogPage.qml @@ -75,6 +75,7 @@ SplitView { header: StatusDialogHeader { //color: Theme.palette.baseColor3 color: !!ctrlHeaderBgColor.text ? ctrlHeaderBgColor.text : Theme.palette.statusModal.backgroundColor + dropShadowEnabled: ctrlHeaderDropShadow.checked visible: dialog.title || dialog.subtitle headline.title: dialog.title @@ -233,6 +234,10 @@ SplitView { id: ctrlDropShadow text: "Footer drop shadow" } + CheckBox { + id: ctrlHeaderDropShadow + text: "Header drop shadow" + } } } } diff --git a/storybook/pages/TokenSelectorButtonPage.qml b/storybook/pages/TokenSelectorButtonPage.qml index baafa351536..a13ca65306a 100644 --- a/storybook/pages/TokenSelectorButtonPage.qml +++ b/storybook/pages/TokenSelectorButtonPage.qml @@ -36,6 +36,9 @@ Pane { selected: selectionCheckBox.checked forceHovered: forceHoveredCheckBox.checked + size: smallCheckBox.checked ? TokenSelectorButton.Size.Small : + TokenSelectorButton.Size.Normal + name: "My token" + (longNameCheckBox.checked ? " long name" : "") icon: Constants.tokenIcon("CFI") @@ -61,6 +64,13 @@ Pane { text: "force hovered" } + + + CheckBox { + id: smallCheckBox + + text: "small" + } } Settings { diff --git a/storybook/pages/TokenSelectorPage.qml b/storybook/pages/TokenSelectorPage.qml index a73daa9fb81..b977a104be5 100644 --- a/storybook/pages/TokenSelectorPage.qml +++ b/storybook/pages/TokenSelectorPage.qml @@ -190,6 +190,9 @@ Pane { anchors.centerIn: parent + size: smallCheckbox.checked ? TokenSelectorButton.Size.Small : + TokenSelectorButton.Size.Normal + assetsModel: assetsModelCheckBox.checked ? assetsModel : null collectiblesModel: collectiblesModelCheckBox.checked ? collectiblesModel : null @@ -215,6 +218,12 @@ Pane { checked: true text: "Collectibles model assigned" } + + CheckBox { + id: smallCheckbox + + text: "small" + } } } diff --git a/storybook/src/Models/NetworksModel.qml b/storybook/src/Models/NetworksModel.qml index e2214532c25..e3cb64a5ff9 100644 --- a/storybook/src/Models/NetworksModel.qml +++ b/storybook/src/Models/NetworksModel.qml @@ -12,6 +12,14 @@ QtObject { readonly property int testnetNet: 5 readonly property int customNet: 6 + readonly property int mainnetChainId: 1 + readonly property int sepMainnetChainId: 11155111 + readonly property int optChainId: 10 + readonly property int sepOptChainId: 11155420 + readonly property int arbChainId: 42161 + readonly property int sepArbChainId: 421614 + + function getShortChainName(chainId) { if(chainId === root.ethNet) return "eth" @@ -55,7 +63,7 @@ QtObject { readonly property var flatNetworks: ListModel { Component.onCompleted: append([ { - chainId: 1, + chainId: mainnetChainId, chainName: "Mainnet", blockExplorerURL: "https://etherscan.io/", iconUrl: "network/Network=Ethereum", @@ -69,7 +77,7 @@ QtObject { isRouteEnabled: true, }, { - chainId: 11155111, + chainId: sepMainnetChainId, chainName: "Sepolia Mainnet", blockExplorerURL: "https://sepolia.etherscan.io/", iconUrl: "network/Network=Ethereum", @@ -83,7 +91,7 @@ QtObject { isRouteEnabled: true, }, { - chainId: 10, + chainId: optChainId, chainName: "Optimism", blockExplorerURL: "https://optimistic.etherscan.io", iconUrl: "network/Network=Optimism", @@ -97,7 +105,7 @@ QtObject { isRouteEnabled: true, }, { - chainId: 11155420, + chainId: sepOptChainId, chainName: "Optimism Sepolia", blockExplorerURL: "https://sepolia-optimism.etherscan.io/", iconUrl: "network/Network=Optimism", @@ -111,7 +119,7 @@ QtObject { isRouteEnabled: true, }, { - chainId: 42161, + chainId: arbChainId, chainName: "Arbitrum", blockExplorerURL: "https://arbiscan.io/", iconUrl: "network/Network=Arbitrum", @@ -125,7 +133,7 @@ QtObject { isRouteEnabled: true, }, { - chainId: 421614, + chainId: sepArbChainId, chainName: "Arbitrum Sepolia", blockExplorerURL: "https://sepolia-explorer.arbitrum.io/", iconUrl: "network/Network=Arbitrum", diff --git a/storybook/src/Models/WalletAccountsModel.qml b/storybook/src/Models/WalletAccountsModel.qml index 3cb142c77c3..418e96e5924 100644 --- a/storybook/src/Models/WalletAccountsModel.qml +++ b/storybook/src/Models/WalletAccountsModel.qml @@ -3,13 +3,20 @@ import QtQuick 2.15 import utils 1.0 ListModel { + + readonly property string accountAddress1: "0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240" + readonly property string accountAddress2: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8881" + readonly property string accountAddress3: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8882" + readonly property string accountAddress4: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8883" + readonly property string accountAddress5: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8884" + readonly property var data: [ { name: "helloworld", emoji: "😋", colorId: Constants.walletAccountColors.primary, color: "#2A4AF5", - address: "0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240", + address: accountAddress1, walletType: "", canSend: true, position: 0, @@ -53,7 +60,7 @@ ListModel { emoji: "🚗", colorId: Constants.walletAccountColors.army, color: "#216266", - address: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8881", + address: accountAddress2, walletType: Constants.generatedWalletType, canSend: true, position: 3, @@ -79,7 +86,7 @@ ListModel { emoji: "🎨", colorId: Constants.walletAccountColors.magenta, color: "#EC266C", - address: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8882", + address: accountAddress3, walletType: Constants.seedWalletType, canSend: true, position: 1, @@ -114,7 +121,7 @@ ListModel { emoji: "⌚", colorId: Constants.walletAccountColors.copper, color: "#CB6256", - address: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8883", + address: accountAddress4, walletType: Constants.watchWalletType, canSend: false, position: 2, @@ -131,7 +138,7 @@ ListModel { emoji: "🔑", colorId: Constants.walletAccountColors.camel, color: "#C78F67", - address: "0x7F47C2e98a4BBf5487E6fb082eC2D9Ab0E6d8884", + address: accountAddress5, walletType: Constants.keyWalletType, canSend: true, position: 4, diff --git a/ui/StatusQ/src/StatusQ/Components/private/StatusComboboxBackground.qml b/ui/StatusQ/src/StatusQ/Components/private/StatusComboboxBackground.qml index cb660422021..26b594ccdad 100644 --- a/ui/StatusQ/src/StatusQ/Components/private/StatusComboboxBackground.qml +++ b/ui/StatusQ/src/StatusQ/Components/private/StatusComboboxBackground.qml @@ -11,7 +11,7 @@ Rectangle { border.width: 1 border.color: Theme.palette.directColor7 radius: 8 - color: root.active ? Theme.palette.baseColor2 : "transparent" + color: root.active ? Theme.palette.directColor8 : "transparent" HoverHandler { cursorShape: root.enabled ? Qt.PointingHandCursor : undefined } diff --git a/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialog.qml b/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialog.qml index dfd9660230e..bd8a75ca3a6 100644 --- a/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialog.qml +++ b/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialog.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 +import QtQuick.Window 2.15 import QtQuick.Layouts 1.15 import QtQml.Models 2.15 import QtQml 2.15 @@ -32,7 +33,8 @@ Dialog { anchors.centerIn: Overlay.overlay padding: 16 - margins: 64 + // by design + margins: root.contentItem.Window.window.height <= 780 ? 28: 64 modal: true // workaround for https://bugreports.qt.io/browse/QTBUG-87804 diff --git a/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialogHeader.qml b/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialogHeader.qml index 1d2c216f7de..ce11c13ece4 100644 --- a/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialogHeader.qml +++ b/ui/StatusQ/src/StatusQ/Popups/Dialog/StatusDialogHeader.qml @@ -1,5 +1,6 @@ import QtQuick 2.14 import QtQuick.Layouts 1.14 +import QtGraphicalEffects 1.15 import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 @@ -9,6 +10,7 @@ Rectangle { readonly property alias headline: headline readonly property alias actions: actions + property bool dropShadowEnabled property alias leftComponent: leftComponentLoader.sourceComponent @@ -63,4 +65,12 @@ Rectangle { anchors.bottom: parent.bottom width: parent.width } + + layer.enabled: root.dropShadowEnabled + layer.effect: DropShadow { + horizontalOffset: 0 + verticalOffset: 2 + samples: 37 + color: Theme.palette.dropShadow + } } diff --git a/ui/app/AppLayouts/Wallet/adaptors/CollectiblesSelectionAdaptor.qml b/ui/app/AppLayouts/Wallet/adaptors/CollectiblesSelectionAdaptor.qml index 2bc4fb9ca3d..02b451d2fe4 100644 --- a/ui/app/AppLayouts/Wallet/adaptors/CollectiblesSelectionAdaptor.qml +++ b/ui/app/AppLayouts/Wallet/adaptors/CollectiblesSelectionAdaptor.qml @@ -46,6 +46,7 @@ QObject { Expected model structure: symbol [string] - unique identifier of a collectible + chainId [int] - unique identifier of a network collectionUid [string] - unique identifier of a collection contractAddress [string] - collectible's contract address name [string] - collectible's name e.g. "Magicat" @@ -74,6 +75,9 @@ QObject { **/ readonly property alias model: communityGroupsGrouppedByCollection + // In case collectibles are to be shown only on specific networks + property var enabledChainIds: [] + LeftJoinModel { id: jointCollectiblesByNwChainId leftModel: collectiblesModel ?? null @@ -126,10 +130,18 @@ QObject { exposedRoles: ["balance", "groupingValue", "icon", "key"] } - filters: RangeFilter { /* 5 */ - roleName: "balance" - minimumValue: 1 - } + filters: [ + RangeFilter { /* 5 */ + roleName: "balance" + minimumValue: 1 + }, + // remove tokens not available on selected network(s) + FastExpressionFilter { + expression: root.enabledChainIds.includes(model.chainId) + expectedRoles: ["chainId"] + enabled: root.enabledChainIds.length + } + ] sorters: [ /* 6 */ RoleSorter { diff --git a/ui/app/AppLayouts/Wallet/controls/TokenSelector.qml b/ui/app/AppLayouts/Wallet/controls/TokenSelector.qml index ff6dd52d9a5..28289ff3ba7 100644 --- a/ui/app/AppLayouts/Wallet/controls/TokenSelector.qml +++ b/ui/app/AppLayouts/Wallet/controls/TokenSelector.qml @@ -17,6 +17,9 @@ Control { /** Expected model structure: see SearchableCollectiblesPanel::model **/ property alias collectiblesModel: tokenSelectorPanel.collectiblesModel + /** Sets size of the TokenSelectorButton **/ + property alias size: tokenSelectorButton.size + readonly property bool isTokenSelected: tokenSelectorButton.selected signal assetSelected(string key) @@ -28,10 +31,19 @@ Control { property alias currentTab: tokenSelectorPanel.currentTab function setSelection(name: string, icon: url, key: string) { - tokenSelectorButton.selected = true - tokenSelectorButton.name = name - tokenSelectorButton.icon = icon - tokenSelectorPanel.highlightedKey = key ?? "" + // reset token selector in case of empty call + if (!key && !name && !icon) { + tokenSelectorButton.selected = false + } else { + tokenSelectorButton.selected = true + tokenSelectorButton.name = name + tokenSelectorButton.icon = icon + tokenSelectorPanel.highlightedKey = key ?? "" + } + } + + function close() { + dropdown.close() } QObject { @@ -54,7 +66,6 @@ Control { y: parent.height + 4 width: 448 - closePolicy: Popup.CloseOnPressOutsideParent horizontalPadding: 0 bottomPadding: 0 diff --git a/ui/app/AppLayouts/Wallet/controls/TokenSelectorButton.qml b/ui/app/AppLayouts/Wallet/controls/TokenSelectorButton.qml index 3f26c28ffad..0ad00f4ca9c 100644 --- a/ui/app/AppLayouts/Wallet/controls/TokenSelectorButton.qml +++ b/ui/app/AppLayouts/Wallet/controls/TokenSelectorButton.qml @@ -20,9 +20,17 @@ Control { property string name property url icon + /** Sets size of the Token Selector Button **/ + property int size: TokenSelectorButton.Size.Normal + signal clicked - padding: 10 + enum Size { + Small, + Normal + } + + padding: root.selected ? 0 : 10 background: StatusComboboxBackground { border.width: 0 @@ -83,7 +91,9 @@ Control { Layout.fillWidth: true objectName: "tokenSelectorContentItemText" - font.pixelSize: 28 + font.pixelSize: root.size === TokenSelectorButton.Size.Normal ? 28 : 22 + lineHeightMode: Text.FixedHeight + lineHeight: root.size === TokenSelectorButton.Size.Normal ? 38 : 30 color: root.hovered ? Theme.palette.blue : Theme.palette.darkBlue elide: Text.ElideRight diff --git a/ui/app/AppLayouts/Wallet/panels/RecipientSelectorPanel.qml b/ui/app/AppLayouts/Wallet/panels/RecipientSelectorPanel.qml new file mode 100644 index 00000000000..d7b5f3dd817 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/panels/RecipientSelectorPanel.qml @@ -0,0 +1,185 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQml.Models 2.1 + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 as StatusQUtils +import StatusQ.Controls 0.1 +import StatusQ.Components 0.1 + +import shared.controls 1.0 as SharedControls +// TODO: remove all files and dependencies with this location once old send modal is removed +import shared.popups.send.controls 1.0 +import shared.popups.send 1.0 + +import utils 1.0 + +import AppLayouts.Wallet.views 1.0 + +Rectangle { + id: root + + required property var savedAddressesModel + required property var myAccountsModel + required property var recentRecipientsModel + + property alias selectedRecipientAddress: recipientInputLoader.selectedRecipientAddress + property alias selectedRecipientType: recipientInputLoader.selectedRecipientType + + signal resolveENS(string ensName, string uuid) + + function ensNameResolved(resolvedPubKey, resolvedAddress, uuid) { + recipientInputLoader.ensNameResolved(resolvedPubKey, resolvedAddress, uuid) + } + + implicitHeight: childrenRect.height + color: Theme.palette.indirectColor1 + radius: 8 + + ColumnLayout { + id: layout + + width: parent.width + spacing: 0 + + RecipientView { + id: recipientInputLoader + + Layout.fillWidth: true + + savedAddressesModel: root.savedAddressesModel + myAccountsModel: root.myAccountsModel + + onResolveENS: root.resolveENS(ensName, uuid) + } + + StatusTabBar { + id: recipientTypeTabBar + + objectName: "recipientTypeTabBar" + + Layout.alignment: Qt.AlignTop + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + Layout.topMargin: 12 + + StatusTabButton { + width: implicitWidth + objectName: "recentAddressesTab" + text: qsTr("Recent") + } + StatusTabButton { + width: implicitWidth + objectName: "savedAddressesTab" + text: qsTr("Saved") + } + StatusTabButton { + width: implicitWidth + objectName: "myAccountsTab" + text: qsTr("My Accounts") + } + + visible: !root.selectedRecipientAddress + } + + Repeater { + id: repeater + + Layout.alignment: Qt.AlignTop + Layout.fillWidth: true + + model: { + switch(recipientTypeTabBar.currentIndex) { + case 0: + return recentsObjModel + case 1: + return savedObjModel + case 2: + return myAccountsObjModel + } + } + } + } + + DelegateModel { + id: recentsObjModel + + model: root.recentRecipientsModel + delegate: StatusListItem { + id: listItem + + property var entry: model.activityEntry + property bool isIncoming: entry.txType === Constants.TransactionType.Receive + + Layout.fillWidth: true + title: isIncoming ? StatusQUtils.Utils.elideText(entry.sender,6,4) : StatusQUtils.Utils.elideText(entry.recipient,6,4) + subTitle: LocaleUtils.getTimeDifference(new Date(parseInt(entry.timestamp) * 1000), new Date()) + statusListItemTitle.elide: Text.ElideMiddle + statusListItemTitle.wrapMode: Text.NoWrap + radius: 0 + color: sensor.containsMouse || highlighted ? Theme.palette.baseColor2 : "transparent" + statusListItemComponentsSlot.spacing: 5 + components: [ + StatusIcon { + id: transferIcon + height: 15 + width: 15 + color: listItem.isIncoming ? Theme.palette.successColor1 : Theme.palette.dangerColor1 + icon: listItem.isIncoming ? "arrow-down" : "arrow-up" + rotation: 45 + }, + StatusTextWithLoadingState { + text: LocaleUtils.currencyAmountToLocaleString(entry.amountCurrency) + } + ] + onClicked: { + root.selectedRecipientType = Helpers.RecipientAddressObjectType.RecentsAddress + let isIncoming = entry.txType === Constants.TransactionType.Receive + let selectedAddress = isIncoming ? entry.sender : entry.recipient + root.selectedRecipientAddress = selectedAddress + } + visible: !root.selectedRecipientAddress + } + } + + DelegateModel { + id: savedObjModel + + model: root.savedAddressesModel + delegate: SavedAddressListItem { + Layout.fillWidth: true + modelData: model + onClicked: { + root.selectedRecipientType = Helpers.RecipientAddressObjectType.SavedAddress + root.selectedRecipientAddress = modelData.address + } + visible: !root.selectedRecipientAddress + } + } + + DelegateModel { + id: myAccountsObjModel + + model: root.myAccountsModel + delegate: SharedControls.WalletAccountListItem { + required property var model + + Layout.fillWidth: true + + name: model.name + address: model.address + emoji: model.emoji + walletColor: Utils.getColorForId(model.colorId) + currencyBalance: model.currencyBalance + walletType: model.walletType + migratedToKeycard: model.migratedToKeycard ?? false + accountBalance: model.accountBalance ?? null + onClicked: { + root.selectedRecipientType = Helpers.RecipientAddressObjectType.Account + root.selectedRecipientAddress = model.address + } + visible: !root.selectedRecipientAddress + } + } +} diff --git a/ui/app/AppLayouts/Wallet/panels/SendModalHeader.qml b/ui/app/AppLayouts/Wallet/panels/SendModalHeader.qml new file mode 100644 index 00000000000..2bec2349ee7 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/panels/SendModalHeader.qml @@ -0,0 +1,158 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 + +import AppLayouts.Wallet.controls 1.0 + +import utils 1.0 + +RowLayout { + id: root + + /** + Expected model structure: + - tokensKey: unique string ID of the token (asset); e.g. "ETH" or contract address + - name: user visible token name (e.g. "Ethereum") + - symbol: user visible token symbol (e.g. "ETH") + - decimals: number of decimal places + - communityId:optional; ID of the community this token belongs to, if any + - marketDetails: object containing props like `currencyPrice` for the computed values below + - balances: submodel[ chainId:int, account:string, balance:BigIntString, iconUrl:string ] + - currentBalance: amount of tokens + - currencyBalance: e.g. `1000.42` in user's fiat currency + - currencyBalanceAsString: e.g. "1 000,42 CZK" formatted as a string according to the user's locale + - balanceAsString: `1.42` formatted as e.g. "1,42" in user's locale + - iconSource: string + **/ + required property var assetsModel + /** + Expected model structure: + - groupName: group name (from collection or community name) + - icon: from imageUrl or mediaUrl + - type: can be "community" or "other" + - subitems: submodel of collectibles/collections of the group + - key: key of collection (community type) or collectible (other type) + - name: name of the subitem (of collectible or collection) + - balance: balance of collection (in case of community collectibles) + or collectible (in case of ERC-1155) + - icon: icon of the subitem + **/ + required property var collectiblesModel + /** + Expected model structure: + - chainId: network chain id + - chainName: name of network + - iconUrl: network icon url + **/ + required property var networksModel + + /** input property holds header is being scrolled **/ + property bool isScrolling + + /** input property holds if the header is the sticky header **/ + property bool isStickyHeader + + /** input property for programatic selection of network **/ + property int selectedChainId + + /** signal to propagate that an asset was selected **/ + signal assetSelected(string key) + /** signal to propagate that a collection was selected **/ + signal collectionSelected(string key) + /** signal to propagate that a collectible was selected **/ + signal collectibleSelected(string key) + /** signal to propagate that a network was selected **/ + signal networkSelected(int chainId) + + /** input function for programatic selection of token + (asset/collectible/collection) **/ + function setToken(name, icon, key) { + tokenSelector.setSelection(name, icon, key) + } + + implicitHeight: sendModalTitleText.height + + spacing: 8 + + // if not closed during scrolling they move with the header and it feels undesirable + onIsScrollingChanged: { + tokenSelector.close() + networkFilter.control.popup.close() + } + + StatusBaseText { + id: sendModalTitleText + + Layout.preferredWidth: contentWidth + + lineHeightMode: Text.FixedHeight + lineHeight: root.isStickyHeader ? 30 : 38 + font.pixelSize: root.isStickyHeader ? 22 : 28 + elide: Text.ElideRight + + text: qsTr("Send") + } + + TokenSelector { + id: tokenSelector + + Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + + size: root.isStickyHeader ? + TokenSelectorButton.Size.Small: + TokenSelectorButton.Size.Normal + + assetsModel: root.assetsModel + collectiblesModel: root.collectiblesModel + + onCollectibleSelected: root.collectibleSelected(key) + onCollectionSelected: root.collectionSelected(key) + onAssetSelected: root.assetSelected(key) + } + + // Horizontal spacer + RowLayout {} + + StatusBaseText { + Layout.alignment: Qt.AlignRight + + text: qsTr("On:") + color: Theme.palette.baseColor1 + font.pixelSize: 13 + lineHeight: 38 + lineHeightMode: Text.FixedHeight + verticalAlignment: Text.AlignVCenter + + visible: networkFilter.visible + } + + NetworkFilter { + id: networkFilter + + Layout.alignment: Qt.AlignTop + + control.popup.y: networkFilter.height + + flatNetworks: root.networksModel + + multiSelection: false + showSelectionIndicator: false + showTitle: false + + Binding on selection { + value: [root.selectedChainId] + when: root.selectedChainId !== 0 + } + onSelectionChanged: { + if (root.selectedChainId !== selection[0]) { + root.networkSelected(selection[0]) + } + } + + onToggleNetwork: root.networkSelected(chainId) + } +} diff --git a/ui/app/AppLayouts/Wallet/panels/SimpleTransactionsFees.qml b/ui/app/AppLayouts/Wallet/panels/SimpleTransactionsFees.qml new file mode 100644 index 00000000000..cfcde4396d6 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/panels/SimpleTransactionsFees.qml @@ -0,0 +1,92 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Components 0.1 + +Control { + id: root + + /** property to set fees in fiat along with fiat symbol **/ + property string cryptoFees + /** property to set fees in crypto along with crypto symbol **/ + property string fiatFees + /** property to set loading state in the fees component **/ + property bool loading + /** property to set error state in the fees component **/ + property bool error + + QtObject { + id: d + + readonly property string loadingText: "XXXXXXXXXX" + } + + implicitHeight: 64 + + padding: Theme.padding + verticalPadding: 12 + + background: Rectangle { + color: Theme.palette.indirectColor1 + radius: Theme.radius + } + + contentItem: RowLayout { + width: parent.width + spacing: 12 + + StatusRoundIcon { + Layout.alignment: Qt.AlignTop + + radius: 8 + asset.name: "gas" + asset.color: Theme.palette.directColor1 + } + ColumnLayout { + Layout.fillWidth: true + + spacing: 0 + + StatusBaseText { + Layout.fillWidth: true + + lineHeightMode: Text.FixedHeight + lineHeight: 22 + + text: qsTr("Est Mainnet transaction fee") + } + StatusTextWithLoadingState { + id: cryptoFeesText + + Layout.fillWidth: true + + loading: root.loading + customColor: root.error ? Theme.palette.dangerColor1: + Theme.palette.baseColor1 + lineHeightMode: Text.FixedHeight + lineHeight: 22 + + text: !!root.cryptoFees ? root.cryptoFees: + d.loadingText + } + } + StatusTextWithLoadingState { + id: fiatFeesText + + Layout.alignment: Qt.AlignRight + + loading: root.loading + customColor: root.error ? Theme.palette.dangerColor1: + Theme.palette.baseColor1 + lineHeightMode: Text.FixedHeight + lineHeight: 22 + + text: !!root.fiatFees ? root.fiatFees: + d.loadingText + } + } +} diff --git a/ui/app/AppLayouts/Wallet/panels/StickySendModalHeader.qml b/ui/app/AppLayouts/Wallet/panels/StickySendModalHeader.qml new file mode 100644 index 00000000000..f4cae8d46aa --- /dev/null +++ b/ui/app/AppLayouts/Wallet/panels/StickySendModalHeader.qml @@ -0,0 +1,134 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtGraphicalEffects 1.15 + +import StatusQ.Core.Theme 0.1 +import StatusQ.Popups.Dialog 0.1 + +Control { + id: root + + /** + Expected model structure: + - tokensKey: unique string ID of the token (asset); e.g. "ETH" or contract address + - name: user visible token name (e.g. "Ethereum") + - symbol: user visible token symbol (e.g. "ETH") + - decimals: number of decimal places + - communityId:optional; ID of the community this token belongs to, if any + - marketDetails: object containing props like `currencyPrice` for the computed values below + - balances: submodel[ chainId:int, account:string, balance:BigIntString, iconUrl:string ] + - currentBalance: amount of tokens + - currencyBalance: e.g. `1000.42` in user's fiat currency + - currencyBalanceAsString: e.g. "1 000,42 CZK" formatted as a string according to the user's locale + - balanceAsString: `1.42` formatted as e.g. "1,42" in user's locale + - iconSource: string + **/ + required property var assetsModel + /** + Expected model structure: + - groupName: group name (from collection or community name) + - icon: from imageUrl or mediaUrl + - type: can be "community" or "other" + - subitems: submodel of collectibles/collections of the group + - key: key of collection (community type) or collectible (other type) + - name: name of the subitem (of collectible or collection) + - balance: balance of collection (in case of community collectibles) + or collectible (in case of ERC-1155) + - icon: icon of the subitem + **/ + required property var collectiblesModel + /** + Expected model structure: + - chainId: network chain id + - chainName: name of network + - iconUrl: network icon url + **/ + required property var networksModel + + /** this property decided if the sticky header is visible or not. + Not using visible property directly here as the animation on + implicitHeight doesnt work + **/ + property bool stickyHeaderVisible + + /** input property for programatic selection of network **/ + property int selectedChainId + + /** signal to propagate that an asset was selected **/ + signal assetSelected(string key) + /** signal to propagate that a collection was selected **/ + signal collectionSelected(string key) + /** signal to propagate that a collectible was selected **/ + signal collectibleSelected(string key) + /** signal to propagate that a network was selected **/ + signal networkSelected(int chainId) + + /** input function for programatic selection of token + (asset/collectible/collection) **/ + function setToken(name, icon, key) { + sendModalHeader.setToken(name, icon, key) + } + + QtObject { + id: d + readonly property int bottomMargin: 12 + } + + implicitHeight: root.stickyHeaderVisible ? + implicitContentHeight + Theme.padding + d.bottomMargin : 0 + + horizontalPadding: Theme.xlPadding + bottomPadding: d.bottomMargin + topPadding: root.stickyHeaderVisible ? Theme.padding : -implicitContentHeight - Theme.padding + + Behavior on implicitHeight { + NumberAnimation { duration: 350 } + } + Behavior on topPadding { + NumberAnimation { duration: 350 } + } + + background: Rectangle { + color: root.implicitHeight > d.bottomMargin ? Theme.palette.baseColor3: Theme.palette.transparent + radius: 8 + + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 0 + verticalOffset: 2 + samples: 37 + color: Theme.palette.dropShadow + } + + // cover for the bottom rounded corners + Rectangle { + width: parent.width + height: parent.radius + anchors.bottom: parent.bottom + color: parent.color + } + + StatusDialogDivider { + anchors.bottom: parent.bottom + width: parent.width + } + } + + contentItem: SendModalHeader { + id: sendModalHeader + + isStickyHeader: true + isScrolling: root.stickyHeaderVisible + + networksModel: root.networksModel + assetsModel: root.assetsModel + collectiblesModel: root.collectiblesModel + + selectedChainId: root.selectedChainId + + onCollectibleSelected: root.collectibleSelected(key) + onCollectionSelected: root.collectionSelected(key) + onAssetSelected: root.assetSelected(key) + onNetworkSelected: root.networkSelected(chainId) + } +} diff --git a/ui/app/AppLayouts/Wallet/panels/qmldir b/ui/app/AppLayouts/Wallet/panels/qmldir index 8d837dfea89..647aff01cb8 100644 --- a/ui/app/AppLayouts/Wallet/panels/qmldir +++ b/ui/app/AppLayouts/Wallet/panels/qmldir @@ -12,3 +12,7 @@ SignInfoBox 1.0 SignInfoBox.qml SwapInputPanel 1.0 SwapInputPanel.qml TokenSelectorPanel 1.0 TokenSelectorPanel.qml WalletHeader 1.0 WalletHeader.qml +SendModalHeader 1.0 SendModalHeader.qml +StickySendModalHeader 1.0 StickySendModalHeader.qml +RecipientSelectorPanel 1.0 RecipientSelectorPanel.qml +SimpleTransactionsFees 1.0 SimpleTransactionsFees.qml diff --git a/ui/app/AppLayouts/Wallet/popups/simpleSend/SimpleSendModal.qml b/ui/app/AppLayouts/Wallet/popups/simpleSend/SimpleSendModal.qml index 40fe860ca2c..352d2ce602c 100644 --- a/ui/app/AppLayouts/Wallet/popups/simpleSend/SimpleSendModal.qml +++ b/ui/app/AppLayouts/Wallet/popups/simpleSend/SimpleSendModal.qml @@ -1,17 +1,488 @@ import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import StatusQ 0.1 +import StatusQ.Core 0.1 +import StatusQ.Controls 0.1 import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Popups.Dialog 0.1 +import StatusQ.Core.Backpressure 0.1 + +import shared.popups.send.views 1.0 +import shared.controls 1.0 + +import AppLayouts.Wallet.panels 1.0 +import AppLayouts.Wallet.controls 1.0 +import AppLayouts.Wallet.views 1.0 +import AppLayouts.Wallet 1.0 + +import utils 1.0 StatusDialog { - id: popup + id: root + + /** + TODO: use the newly defined WalletAccountsSelectorAdaptor + in https://github.com/status-im/status-desktop/pull/16834 + This will also remove watch only accounts from the list + Expected model structure: + - name: name of account + - address: wallet address + - color: color of the account + - emoji: emoji selected for the account + - currencyBalance: total currency balance in CurrencyAmount + - accountBalance: balance of selected token + selected chain + **/ + required property var accountsModel + /** + Expected model structure: + - tokensKey: unique string ID of the token (asset); e.g. "ETH" or contract address + - name: user visible token name (e.g. "Ethereum") + - symbol: user visible token symbol (e.g. "ETH") + - decimals: number of decimal places + - communityId:optional; ID of the community this token belongs to, if any + - marketDetails: object containing props like `currencyPrice` for the computed values below + - balances: submodel[ chainId:int, account:string, balance:BigIntString, iconUrl:string ] + - currentBalance: amount of tokens + - currencyBalance: e.g. `1000.42` in user's fiat currency + - currencyBalanceAsString: e.g. "1 000,42 CZK" formatted as a string according to the user's locale + - balanceAsString: `1.42` formatted as e.g. "1,42" in user's locale + - iconSource: string + **/ + required property var assetsModel + /** + Expected model structure: + - groupName: group name (from collection or community name) + - icon: from imageUrl or mediaUrl + - type: can be "community" or "other" + - subitems: submodel of collectibles/collections of the group + - key: key of collection (community type) or collectible (other type) + - name: name of the subitem (of collectible or collection) + - balance: balance of collection (in case of community collectibles) + or collectible (in case of ERC-1155) + - icon: icon of the subitem + **/ + required property var collectiblesModel + /** + Expected model structure: + - chainId: network chain id + - chainName: name of network + - iconUrl: network icon url + Only networks valid as per mainnet/testnet selection + **/ + required property var networksModel + required property var savedAddressesModel + required property var recentRecipientsModel + /** Input property holds currently selected Fiat currency **/ + required property string currentCurrency + /** Input function to format currency amount to locale string **/ + required property var fnFormatCurrencyAmount + + /** input property to decide if send modal is interactive or prefilled **/ + property bool interactive: true + + /** input property to set estimated time **/ + property string estimatedTime + /** input property to set estimated fees in fiat **/ + property string estimatedFiatFees + /** input property to set estimated fees in crypto **/ + property string estimatedCryptoFees + + /** property to set and expose currently selected account **/ + property string selectedAccountAddress + /** property to set and expose currently selected network **/ + property int selectedChainId + /** property to set and expose currently selected token key **/ + property string selectedTokenKey + /** property to set and expose the amount to send from outside without any localization **/ + property string selectedAmount + /** output property to set currently set amount to send + Crypto value in a base unit as a string integer, + e.g. 1000000000000000000 for 1 ETH **/ + readonly property string selectedAmountInBaseUnit: amountToSend.amount + + /** property to scheck if form has been filled correctly **/ + readonly property bool formCorrectlyFilled: d.allValuesFilledCorrectly() + + /** TODO: replace with new and improved recipient selector StatusDateRangePicker + TBD under https://github.com/status-im/status-desktop/issues/16916 **/ + property alias selectedRecipientAddress: recipientsPanel.selectedRecipientAddress + /** Input function to resolve Ens Name **/ + required property var fnResolveENS + /** Output function to set resolved ens name values **/ + function ensNameResolved(resolvedPubKey, resolvedAddress, uuid) { + recipientsPanel.ensNameResolved(resolvedPubKey, resolvedAddress, uuid) + } + + /** Output signal to request signing of the transaction **/ + signal reviewSendClicked() + /** Output signal to inform that the forms been updated **/ + signal formChanged() + + QtObject { + id: d + + readonly property real scrollViewContentY: scrollView.flickable.contentY + onScrollViewContentYChanged: { + const buffer = sendModalHeader.height + scrollViewLayout.spacing + if (scrollViewContentY > buffer) { + d.stickyHeaderVisible = true + } else if (scrollViewContentY === 0) { + d.stickyHeaderVisible = false + } + } + property bool stickyHeaderVisible: false + + // Used to get asset entry if selected token is an asset + readonly property var selectedAssetEntry: ModelEntry { + sourceModel: root.assetsModel + key: "tokensKey" + value: root.selectedTokenKey + } + + // Used to get collectible entry if selected token is a collectible + readonly property var selectedCollectibleEntry: ModelEntry { + sourceModel: root.collectiblesModel + key: "symbol" + value: root.selectedTokenKey + } + + /** exposes the currently selected token entry **/ + readonly property var selectedTokenEntry: selectedAssetEntry.available ? + selectedAssetEntry.item : + selectedCollectibleEntry.available ? + selectedCollectibleEntry.item: null + onSelectedTokenEntryChanged: { + if(!selectedAssetEntry.available && !selectedCollectibleEntry.available) { + d.debounceResetTokenSelector() + } + if(selectedAssetEntry.available && !!selectedTokenEntry) { + d.setTokenOnBothHeaders(selectedTokenEntry.symbol, + Constants.tokenIcon(selectedTokenEntry.symbol), + selectedTokenEntry.tokensKey) + } + else if(selectedCollectibleEntry.available && !!selectedTokenEntry) { + const id = selectedTokenEntry.communityId ? + selectedTokenEntry.collectionUid : + selectedTokenEntry.uid + d.setTokenOnBothHeaders(selectedTokenEntry.name, + selectedTokenEntry.imageUrl || selectedTokenEntry.mediaUrl, + id) + } + } + + function setTokenOnBothHeaders(name, icon, key) { + sendModalHeader.setToken(name, icon, key) + stickySendModalHeader.setToken(name, icon, key) + } + + readonly property var debounceResetTokenSelector: Backpressure.debounce(root, 0, function() { + if(!selectedAssetEntry.available && !selectedCollectibleEntry.available) { + // reset token selector in case selected tokens doesnt exist in either models + d.setTokenOnBothHeaders("", "", "") + root.selectedTokenKey = "" + } + }) + + readonly property var debounceSetSelectedAmount: Backpressure.debounce(root, 1500, function() { + root.selectedAmount = amountToSend.text + }) + + readonly property bool isCollectibleSelected: { + if(!selectedTokenEntry) + return false + const type = selectedAssetEntry.available ? selectedAssetEntry.item.type : + selectedCollectibleEntry.available ? selectedCollectibleEntry.item.tokenType : + Constants.TokenType.Unknown + return (type === Constants.TokenType.ERC721 || type === Constants.TokenType.ERC1155) + } + + readonly property string selectedCryptoTokenSymbol: !!d.selectedTokenEntry ? + d.selectedTokenEntry.symbol: "" - title: qsTr("Send") + readonly property double maxSafeCryptoValue: { + const maxCryptoBalance = !!d.selectedTokenEntry && !!d.selectedTokenEntry.currentBalance ? + d.selectedTokenEntry.currentBalance : 0 + return WalletUtils.calculateMaxSafeSendAmount(maxCryptoBalance, d.selectedCryptoTokenSymbol) + } + function allValuesFilledCorrectly() { + return !!root.selectedAccountAddress && + root.selectedChainId !== 0 && + !!root.selectedTokenKey && + !!root.selectedRecipientAddress && + !!root.selectedAmount && + !amountToSend.markAsInvalid && + amountToSend.valid + } + + // handle multiple property changes from single changed signal + property var combinedPropertyChangedHandler: [ + root.selectedAccountAddress, + root.selectedChainId, + root.selectedTokenKey, + root.selectedRecipientAddress, + root.selectedAmount, + amountToSend.markAsInvalid, + amountToSend.valid] + onCombinedPropertyChangedHandlerChanged: Qt.callLater(() => root.formChanged()) + + readonly property bool feesIsLoading: !root.estimatedCryptoFees && + !root.estimatedFiatFees && + !root.estimatedTime + } + + width: 556 padding: 0 + horizontalPadding: Theme.xlPadding + topMargin: margins + accountSelector.height + Theme.padding + background: StatusDialogBackground { - implicitHeight: 846 - implicitWidth: 556 color: Theme.palette.baseColor3 } + + // Bindings needed for exposing and setting raw values from AmountToSend + onSelectedAmountChanged: { + if(!!selectedAmount && amountToSend.text !== root.selectedAmount) { + amountToSend.setValue(root.selectedAmount) + } + } + + Item { + id: sendModalcontentItem + + anchors.fill: parent + anchors.top: parent.top + + implicitWidth: parent.width + implicitHeight: Math.max(sendModalHeader.height + + amountToSend.height + + recipientsPanelLayout.height + + feesLayout.height + + scrollViewLayout.spacing*3 + + 28, + scrollView.implicitHeight) + + // Floating account Selector + AccountSelectorHeader { + id: accountSelector + + anchors.top: parent.top + anchors.topMargin: -accountSelector.height - Theme.padding + anchors.left: parent.left + anchors.leftMargin: -Theme.xlPadding + + model: root.accountsModel + + selectedAddress: root.selectedAccountAddress + onCurrentAccountAddressChanged: { + if(currentAccountAddress !== root.selectedAccountAddress) { + root.selectedAccountAddress = currentAccountAddress + } + } + } + + // Sticky header only visible when scrolling + Item { + height: childrenRect.height + Theme.smallPadding + anchors.top: accountSelector.bottom + anchors.topMargin: Theme.padding + anchors.left: parent.left + anchors.leftMargin: -Theme.xlPadding + anchors.right: parent.right + anchors.rightMargin: -Theme.xlPadding + + clip: true + z: 1 + + StickySendModalHeader { + id: stickySendModalHeader + + width: parent.width + + stickyHeaderVisible: d.stickyHeaderVisible + + networksModel: root.networksModel + assetsModel: root.assetsModel + collectiblesModel: root.collectiblesModel + + selectedChainId: root.selectedChainId + + onCollectibleSelected: root.selectedTokenKey = key + onCollectionSelected: root.selectedTokenKey = key + onAssetSelected: root.selectedTokenKey = key + onNetworkSelected: root.selectedChainId = chainId + } + } + + // Main scrollable Layout + StatusScrollView { + id: scrollView + + anchors.fill: parent + contentWidth: availableWidth + + padding: 0 + + StatusScrollBar.vertical { + id: verticalScrollbar + + parent: sendModalcontentItem + x: sendModalcontentItem.width + root.rightPadding - verticalScrollbar.width + } + + ColumnLayout { + id: scrollViewLayout + + width: scrollView.availableWidth + spacing: 20 + + // Header that scrolls + SendModalHeader { + id: sendModalHeader + + Layout.fillWidth: true + Layout.topMargin: 28 + + isScrolling: d.stickyHeaderVisible + + networksModel: root.networksModel + assetsModel: root.assetsModel + collectiblesModel: root.collectiblesModel + + selectedChainId: root.selectedChainId + + onCollectibleSelected: root.selectedTokenKey = key + onCollectionSelected: root.selectedTokenKey = key + onAssetSelected: root.selectedTokenKey = key + onNetworkSelected: root.selectedChainId = chainId + } + + // Amount to send entry + AmountToSend { + id: amountToSend + + Layout.fillWidth: true + + interactive: root.interactive + dividerVisible: true + progressivePixelReduction: false + /** TODO: connect this with suggested routes being fetched as price + gets updated each time a new proposal is fetched + bottomTextLoading: root.suggestedRoutesLoading **/ + + /** TODO: connect to max safe value for eth. + For now simply checking balance in case of both eth and other ERC20's **/ + markAsInvalid: SQUtils.AmountsArithmetic.fromNumber(d.maxSafeCryptoValue, multiplierIndex).cmp(amount) === -1 + + selectedSymbol: amountToSend.fiatMode ? + root.currentCurrency: + d.selectedCryptoTokenSymbol + price: !!d.selectedTokenEntry && + !!d.selectedTokenEntry.marketDetails ? + d.selectedTokenEntry.marketDetails.currencyPrice.amount : 1 + multiplierIndex: !!d.selectedTokenEntry && + !!d.selectedTokenEntry.decimals ? + d.selectedTokenEntry.decimals : 0 + formatFiat: amount => root.fnFormatCurrencyAmount( + amount, root.currentCurrency) + formatBalance: amount => root.fnFormatCurrencyAmount( + amount, d.selectedCryptoTokenSymbol) + + visible: !!root.selectedTokenKey && !d.isCollectibleSelected + onVisibleChanged: if(visible) forceActiveFocus() + + onTextChanged: d.debounceSetSelectedAmount() + + bottomRightComponent: MaxSendButton { + id: maxButton + + formattedValue: { + const price = !!d.selectedTokenEntry && !!d.selectedTokenEntry.marketDetails ? + d.selectedTokenEntry.marketDetails.currencyPrice.amount : 0 + let maxSafeValue = amountToSend.fiatMode ? d.maxSafeCryptoValue * price : d.maxSafeCryptoValue + return root.fnFormatCurrencyAmount( + maxSafeValue, + amountToSend.selectedSymbol, + { roundingMode: LocaleUtils.RoundingMode.Down + }) + } + markAsInvalid: amountToSend.markAsInvalid + /** TODO: Remove below customisations after + https://github.com/status-im/status-desktop/issues/15709 + and make the button clickable **/ + enabled: false + background: Rectangle { + radius: 20 + color: type === StatusBaseButton.Type.Danger ? Theme.palette.dangerColor3 : Theme.palette.primaryColor3 + } + disabledTextColor: type === StatusBaseButton.Type.Danger ? Theme.palette.dangerColor1 : Theme.palette.primaryColor1 + } + } + + /** TODO: replace with new and improved recipient selector TBD under + https://github.com/status-im/status-desktop/issues/16916 **/ + ColumnLayout { + id: recipientsPanelLayout + + Layout.fillWidth: true + + spacing: Theme.halfPadding + + StatusBaseText { + elide: Text.ElideRight + text: qsTr("To") + } + RecipientSelectorPanel { + id: recipientsPanel + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.bottomMargin: feesLayout.visible ? 0 : Theme.xlPadding + + savedAddressesModel: root.savedAddressesModel + myAccountsModel: root.accountsModel + recentRecipientsModel: root.recentRecipientsModel + + onResolveENS: root.fnResolveENS(ensName, uuid) + } + } + + // Fees Component + ColumnLayout { + id: feesLayout + + Layout.fillWidth: true + Layout.bottomMargin: Theme.xlPadding + + spacing: Theme.halfPadding + + StatusBaseText { + elide: Text.ElideRight + text: qsTr("Fees") + } + SimpleTransactionsFees { + Layout.fillWidth: true + + cryptoFees: root.estimatedCryptoFees + fiatFees: root.estimatedFiatFees + loading: d.feesIsLoading && d.allValuesFilledCorrectly() + } + visible: d.allValuesFilledCorrectly() + } + } + } + } + + footer: SendModalFooter { + width: root.width + + estimatedTime: root.estimatedTime + estimatedFees: root.estimatedFiatFees + + loading: d.feesIsLoading && d.allValuesFilledCorrectly() + + onReviewSendClicked: root.reviewSendClicked() + } } diff --git a/ui/app/AppLayouts/Wallet/views/RecipientView.qml b/ui/app/AppLayouts/Wallet/views/RecipientView.qml new file mode 100644 index 00000000000..79c4ec15a6c --- /dev/null +++ b/ui/app/AppLayouts/Wallet/views/RecipientView.qml @@ -0,0 +1,193 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Backpressure 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 as StatusQUtils + +import AppLayouts.Wallet 1.0 + +import shared.controls 1.0 as SharedControls +import shared.stores.send 1.0 +import shared.popups.send.panels 1.0 +import shared.popups.send 1.0 +import shared.popups.send.controls 1.0 + +import utils 1.0 + +Loader { + id: root + + required property var savedAddressesModel + required property var myAccountsModel + + property string selectedRecipientAddress + property int selectedRecipientType: Helpers.RecipientAddressObjectType.Address + property bool interactive: true + + signal resolveENS(string ensName, string uuid) + + function ensNameResolved(resolvedPubKey, resolvedAddress, uuid) { + if(uuid !== d.uuid) { + return + } + root.selectedRecipientAddress = resolvedAddress + } + + QtObject { + id: d + + property bool isValidAddress: true + property bool isBeingEvaluated: false + + property string uuid + + readonly property var validateInput: Backpressure.debounce(root, 500, function (address) { + d.isValidAddress = Utils.isValidAddress(address) + const isENSName = Utils.isValidEns(address) + + if(d.isValidAddress) { + root.selectedRecipientAddress = address + d.isBeingEvaluated = false + } + else if(isENSName) { + d.uuid = Utils.uuid() + return root.resolveENS(address, uuid) + } else { + root.selectedRecipientAddress = "" + d.isBeingEvaluated = false + } + }) + + readonly property var accountsSelectedEntry: ModelEntry { + sourceModel: root.myAccountsModel + key: "address" + value: root.selectedRecipientAddress + } + + readonly property var savedAddrSelectedEntry: ModelEntry { + sourceModel: root.savedAddressesModel + key: "address" + value: root.selectedRecipientAddress + + } + + function clearValues() { + root.selectedRecipientAddress = "" + root.selectedRecipientType = Helpers.RecipientAddressObjectType.Address + } + } + + sourceComponent: root.selectedRecipientType === Helpers.RecipientAddressObjectType.SavedAddress ? + savedAddressRecipient: + root.selectedRecipientType === Helpers.RecipientAddressObjectType.Account ? + myAccountRecipient: + root.selectedRecipientType === Helpers.RecipientAddressObjectType.RecentsAddress ? + recentsRecipient : addressRecipient + + Component { + id: savedAddressRecipient + SavedAddressListItem { + implicitWidth: parent.width + modelData: d.savedAddrSelectedEntry.item + radius: 8 + clearVisible: true + color: Theme.palette.indirectColor1 + sensor.enabled: false + subTitle: { + if(!!modelData) { + if (!!modelData && !!modelData.ens && modelData.ens.length > 0) + return Utils.richColorText(modelData.ens, Theme.palette.directColor1) + else + return StatusQUtils.Utils.elideText(modelData.address,6,4) + } + return "" + } + onCleared: d.clearValues() + } + } + + Component { + id: myAccountRecipient + SharedControls.WalletAccountListItem { + id: accountItem + readonly property var modelData: d.accountsSelectedEntry.item + + name: !!modelData ? modelData.name : "" + address: !!modelData ? modelData.address : "" + emoji: !!modelData ? modelData.emoji : "" + walletColor: !!modelData ? Utils.getColorForId(modelData.colorId): "" + currencyBalance: !!modelData ? modelData.currencyBalance : "" + walletType: !!modelData ? modelData.walletType : "" + migratedToKeycard: !!modelData ? modelData.migratedToKeycard ?? false : false + accountBalance: !!modelData ? modelData.accountBalance : null + + width: parent.width + radius: 8 + clearVisible: true + color: Theme.palette.indirectColor1 + sensor.enabled: false + subTitle: { + if(!!modelData) { + return StatusQUtils.Utils.elideAndFormatWalletAddress(modelData.address) + } + return "" + } + onCleared: d.clearValues() + } + } + + Component { + id: recentsRecipient + + SendRecipientInput { + width: parent.width + height: visible ? implicitHeight: 0 + + interactive: root.interactive + input.edit.enabled: false + input.edit.textFormat: Text.AutoText + text: root.selectedRecipientAddress + + onClearClicked: d.clearValues() + } + } + + Component { + id: addressRecipient + + SendRecipientInput { + function validateInput() { + const plainText = StatusQUtils.StringUtils.plainText(text) + d.isBeingEvaluated = true + d.validateInput(plainText) + } + + width: parent.width + height: visible ? implicitHeight: 0 + + interactive: root.interactive + checkMarkVisible: !d.isBeingEvaluated && d.isValidAddress + loading: d.isBeingEvaluated + input.edit.textFormat: Text.AutoText + + text: { + if(!!root.selectedRecipientAddress ) { + return root.selectedRecipientAddress + } + return text + } + + onTextChanged: Qt.callLater(() => validateInput()) + onClearClicked: { + text = "" + d.clearValues() + } + onValidateInputRequested: Qt.callLater(() => validateInput()) + } + } +} + diff --git a/ui/app/AppLayouts/Wallet/views/SendModalFooter.qml b/ui/app/AppLayouts/Wallet/views/SendModalFooter.qml new file mode 100644 index 00000000000..33950bae6b2 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/views/SendModalFooter.qml @@ -0,0 +1,103 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQml.Models 2.15 + +import StatusQ.Controls 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Popups.Dialog 0.1 + +StatusDialogFooter { + id: root + + /** property to set loading state **/ + property bool loading + /** property to set estimated time **/ + property string estimatedTime + /** property to set estimates fees in fiat **/ + property string estimatedFees + /** property to set error state **/ + property bool error + + // Signal to propogate Send clicked + signal reviewSendClicked() + + implicitHeight: 82 + spacing: Theme.bigPadding + color: Theme.palette.baseColor3 + dropShadowEnabled: true + + QtObject { + id: d + + readonly property string emptyText: "--" + readonly property string loadingText: "XXXXXXXXXX" + } + + leftButtons: ObjectModel { + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: Theme.padding + + spacing: 0 + + StatusBaseText { + font.weight: Font.Medium + color: Theme.palette.directColor5 + text: qsTr("Est time") + } + StatusTextWithLoadingState { + id: estimatedTime + + font.weight: Font.Medium + customColor: !!root.estimatedTime ? Theme.palette.directColor1: + Theme.palette.directColor5 + loading: root.loading + + text: !!root.estimatedTime ? root.estimatedTime: + root.loading ? d.loadingText : d.emptyText + } + } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + + spacing: 0 + + StatusBaseText { + font.weight: Font.Medium + color: Theme.palette.directColor5 + text: qsTr("Est fees") + } + StatusTextWithLoadingState { + id: estimatedFees + + font.weight: Font.Medium + customColor: root.error ? Theme.palette.dangerColor1: + !!root.estimatedFees ? + Theme.palette.directColor1: + Theme.palette.directColor5 + loading: root.loading + + text: !!root.estimatedFees ? root.estimatedFees: + loading ? d.loadingText : d.emptyText + } + } + } + + rightButtons: ObjectModel { + StatusButton { + objectName: "transactionModalFooterButton" + + Layout.rightMargin: Theme.padding + + disabledColor: Theme.palette.directColor8 + enabled: !!root.estimatedTime && + !!root.estimatedFees && + !root.loading + + text: qsTr("Review Send") + + onClicked: root.reviewSendClicked() + } + } +} diff --git a/ui/app/AppLayouts/Wallet/views/qmldir b/ui/app/AppLayouts/Wallet/views/qmldir index e578333df56..12ef9ae06c1 100644 --- a/ui/app/AppLayouts/Wallet/views/qmldir +++ b/ui/app/AppLayouts/Wallet/views/qmldir @@ -6,3 +6,5 @@ TokenSelectorAssetDelegate 1.0 TokenSelectorAssetDelegate.qml TokenSelectorCollectibleDelegate 1.0 TokenSelectorCollectibleDelegate.qml TokenSelectorSectionDelegate 1.0 TokenSelectorSectionDelegate.qml AccountContextMenu 1.0 AccountContextMenu.qml +RecipientView 1.0 RecipientView.qml +SendModalFooter 1.0 SendModalFooter.qml diff --git a/ui/app/mainui/AppMain.qml b/ui/app/mainui/AppMain.qml index 14cbe7c3dbb..c21dbf6165e 100644 --- a/ui/app/mainui/AppMain.qml +++ b/ui/app/mainui/AppMain.qml @@ -660,10 +660,31 @@ Item { simpleSendEnabled: appMain.featureFlagsStore.simpleSendEnabled + // for simple send + walletAccountsModel: WalletStores.RootStore.accounts + flatNetworksModel: WalletStores.RootStore.flatNetworks + areTestNetworksEnabled: WalletStores.RootStore.areTestNetworksEnabled + groupedAccountAssetsModel: appMain.walletAssetsStore.groupedAccountAssetsModel + currentCurrency: appMain.currencyStore.currentCurrency + showCommunityAssetsInSend: appMain.tokensStore.showCommunityAssetsInSend + collectiblesBySymbolModel: WalletStores.RootStore.collectiblesStore.jointCollectiblesBySymbolModel + fnFormatCurrencyAmount: function(amount, symbol, options = null, locale = null) { + return appMain.currencyStore.formatCurrencyAmount(amount, symbol) + } + // TODO remove this call to mainModule under #16919 + fnResolveENS: function(ensName, uuid) { + mainModule.resolveENS(name, uuid) + } + + savedAddressesModel: WalletStores.RootStore.savedAddresses + recentRecipientsModel: appMain.transactionStore.tempActivityController1Model + Component.onCompleted: { // It's requested from many nested places, so as a workaround we use // Global to shorten the path via global signal. Global.sendToRecipientRequested.connect(sendToRecipient) + // TODO remove this call to mainModule under #16919 + mainModule.resolvedENS.connect(ensNameResolved) } } diff --git a/ui/app/mainui/SendModalHandler.qml b/ui/app/mainui/SendModalHandler.qml index f6abcc1e39d..8104158b598 100644 --- a/ui/app/mainui/SendModalHandler.qml +++ b/ui/app/mainui/SendModalHandler.qml @@ -1,10 +1,13 @@ import QtQuick 2.15 +import SortFilterProxyModel 0.2 + import StatusQ.Core 0.1 import StatusQ.Core.Utils 0.1 as SQUtils import AppLayouts.Wallet.stores 1.0 as WalletStores import AppLayouts.Wallet.popups.simpleSend 1.0 +import AppLayouts.Wallet.adaptors 1.0 import shared.popups.send 1.0 import shared.stores.send 1.0 @@ -19,19 +22,81 @@ QtObject { required property TransactionStore transactionStore required property WalletStores.CollectiblesStore walletCollectiblesStore - // for ens flows + /** for ens flows **/ required property string myPublicKey required property string ensRegisteredAddress - // TODO: This should probably be a property and not a function. Needs changes on backend side + /** TODO: This should probably be a property and not + a function. Needs changes on backend side **/ property var getStatusTokenKey: function() {} - // for sticker flows + /** for sticker flows **/ required property string stickersMarketAddress required property string stickersNetworkId - // Feature flag for single network send until its feature complete + /** Feature flag for single network send until its feature complete **/ required property bool simpleSendEnabled + /** For simple send modal flows, decoupling from transaction store **/ + + /** curently selected fiat currency symbol **/ + required property string currentCurrency + /** Expected model structure: + - name: name of account + - address: wallet address + - color: color of the account + - emoji: emoji selected for the account + - currencyBalance: total currency balance in CurrencyAmount + - accountBalance: balance of selected token + selected chain + **/ + required property var walletAccountsModel + /** Expected model structure: + - tokensKey: unique string ID of the token (asset); e.g. "ETH" or contract address + - name: user visible token name (e.g. "Ethereum") + - symbol: user visible token symbol (e.g. "ETH") + - decimals: number of decimal places + - communityId: optional; ID of the community this token belongs to, if any + - marketDetails: object containing props like `currencyPrice` for the computed values below + - balances: submodel[ chainId:int, account:string, balance:BigIntString, iconUrl:string ] + **/ + required property var groupedAccountAssetsModel + /** Expected model structure: + - symbol [string] - unique identifier of a collectible + - collectionUid [string] - unique identifier of a collection + - contractAddress [string] - collectible's contract address + - name [string] - collectible's name e.g. "Magicat" + - collectionName [string] - collection name e.g. "Crypto Kitties" + - mediaUrl [url] - collectible's media url + - imageUrl [url] - collectible's image url + - communityId [string] - unique identifier of a community for community collectible or empty + - ownership [model] - submodel of balances per chain/account + - balance [int] - balance (always 1 for ERC-721) + - accountAddress [string] - unique identifier of an account + **/ + required property var collectiblesBySymbolModel + /** + Expected model structure: + - chainId: network chain id + - chainName: name of network + - iconUrl: network icon url + networks on both mainnet & testnet + **/ + required property var flatNetworksModel + /** true if testnet mode is on **/ + required property bool areTestNetworksEnabled + /** whether community tokens are shown in send modal + based on a global setting **/ + required property bool showCommunityAssetsInSend + /** required function to format currency amount to locale string **/ + required property var fnFormatCurrencyAmount + + required property var savedAddressesModel + required property var recentRecipientsModel + + /** required function to resolve an ens name **/ + required property var fnResolveENS + /** required signal to receive resolved ens name address **/ + signal ensNameResolved(string resolvedPubKey, string resolvedAddress, string uuid) + function openSend(params = {}) { // TODO remove once simple send is feature complete let sendModalCmp = root.simpleSendEnabled ? simpleSendModalComponent: sendModalComponent @@ -166,7 +231,70 @@ QtObject { readonly property Component simpleSendModalComponent: Component { SimpleSendModal { + id: simpleSendModal + + /** TODO: use the newly defined WalletAccountsSelectorAdaptor + in https://github.com/status-im/status-desktop/pull/16834 **/ + accountsModel: root.walletAccountsModel + assetsModel: assetsSelectorViewAdaptor.outputAssetsModel + collectiblesModel: collectiblesSelectionAdaptor.model + networksModel: root.filteredFlatNetworksModel + + savedAddressesModel: root.savedAddressesModel + recentRecipientsModel: root.recentRecipientsModel + + currentCurrency: root.currentCurrency + fnFormatCurrencyAmount: root.fnFormatCurrencyAmount + fnResolveENS: root.fnResolveENS + onClosed: destroy() + + onFormChanged: { + estimatedCryptoFees = "" + estimatedFiatFees = "" + estimatedTime = "" + if(formCorrectlyFilled) { + // TODO: call stores fetchSuggestedRoutes api + } + } + + TokenSelectorViewAdaptor { + id: assetsSelectorViewAdaptor + + // TODO: remove all store dependecies and add specific properties to the handler instead + assetsModel: root.groupedAccountAssetsModel + flatNetworksModel: root.flatNetworksModel + + currentCurrency: root.currentCurrency + showCommunityAssets: root.showCommunityAssetsInSend + + accountAddress: simpleSendModal.selectedAccountAddress + enabledChainIds: [simpleSendModal.selectedChainId] + } + CollectiblesSelectionAdaptor { + id: collectiblesSelectionAdaptor + + accountKey: simpleSendModal.selectedAccountAddress + enabledChainIds: [simpleSendModal.selectedChainId] + + networksModel: root.filteredFlatNetworksModel + collectiblesModel: SortFilterProxyModel { + sourceModel: root.collectiblesBySymbolModel + filters: ValueFilter { + roleName: "soulbound" + value: false + } + } + } + + Component.onCompleted: { + root.ensNameResolved.connect(ensNameResolved) + } } } + + readonly property var filteredFlatNetworksModel: SortFilterProxyModel { + sourceModel: root.flatNetworksModel + filters: ValueFilter { roleName: "isTest"; value: root.areTestNetworksEnabled } + } } diff --git a/ui/imports/shared/popups/send/views/AmountToSend.qml b/ui/imports/shared/popups/send/views/AmountToSend.qml index eb5b9b88fae..0c612e05430 100644 --- a/ui/imports/shared/popups/send/views/AmountToSend.qml +++ b/ui/imports/shared/popups/send/views/AmountToSend.qml @@ -76,6 +76,10 @@ Control { property bool mainInputLoading property bool bottomTextLoading + /* This allows user to add a component to the right of bottom text + Not set = not shown */ + property alias bottomRightComponent: bottomRightComponent.sourceComponent + /* Allows mark input as invalid when it's valid number but doesn't satisfy * arbitrary external criteria, e.g. is higher than maximum expected value. */ property bool markAsInvalid: false @@ -193,17 +197,16 @@ Control { return validator.maxDecimalDigits + validator.maxIntegralDigits } else { let availableSpaceForAmount = root.availableWidth - currencyField.contentWidth - layout.spacing - return Math.floor(availableSpaceForAmount/textMetrics.tightBoundingRect.width) + // k is a coefficient based on the font style (typically 𝑘 ≈ 0.65) + let digitWidth = textField.font.pixelSize * 0.65 + // remove one for decimal separator + return Math.floor(availableSpaceForAmount/digitWidth) } } - - readonly property TextMetrics textMetrics: TextMetrics { - font: textField.font - text: "0" - } } contentItem: ColumnLayout { + spacing: Theme.halfPadding StatusBaseText { id: captionText @@ -227,8 +230,9 @@ Control { objectName: "amountToSend_textField" - implicitHeight: 44 + Layout.preferredHeight: 44 padding: 0 + leftPadding: 0 background: null readOnly: !root.interactive @@ -238,7 +242,7 @@ Control { : Theme.palette.dangerColor1 placeholderText: { - if (!d.fiatMode || root.fiatDecimalPlaces === 0) + if (!d.fiatMode || root.fiatDecimalPlaces === 0 || !!text) return "0" return "0" + root.decimalPoint @@ -284,67 +288,88 @@ Control { Rectangle { Layout.fillWidth: true Layout.preferredHeight: 1 - Layout.bottomMargin: 12 - color: Theme.palette.separator + Layout.bottomMargin: 4 + color: Theme.palette.directColor8 visible: root.dividerVisible } - StatusBaseText { - id: bottomItem + RowLayout { + Layout.fillWidth: true + StatusBaseText { + id: bottomItem - objectName: "bottomItemText" + objectName: "bottomItemText" - Layout.fillWidth: true + Layout.preferredWidth: contentWidth - text: { - const divisor = SQUtils.AmountsArithmetic.fromExponent( - d.fiatMode ? root.multiplierIndex - : root.fiatDecimalPlaces) - const divided = SQUtils.AmountsArithmetic.div( - SQUtils.AmountsArithmetic.fromString( - d.secondaryValue), divisor) - const asNumber = SQUtils.AmountsArithmetic.toNumber(divided) - - return d.fiatMode ? root.formatBalance(asNumber) - : root.formatFiat(asNumber) - } + text: { + const divisor = SQUtils.AmountsArithmetic.fromExponent( + d.fiatMode ? root.multiplierIndex + : root.fiatDecimalPlaces) + const divided = SQUtils.AmountsArithmetic.div( + SQUtils.AmountsArithmetic.fromString( + d.secondaryValue), divisor) + const asNumber = SQUtils.AmountsArithmetic.toNumber(divided) + + return d.fiatMode ? root.formatBalance(asNumber) + : root.formatFiat(asNumber) + } - elide: Text.ElideMiddle - font.pixelSize: 13 - color: Theme.palette.directColor5 + elide: Text.ElideMiddle + font.pixelSize: 13 + color: Theme.palette.directColor5 - MouseArea { - objectName: "amountToSend_mouseArea" + MouseArea { + objectName: "amountToSend_mouseArea" - anchors.fill: parent - cursorShape: enabled ? Qt.PointingHandCursor : undefined - enabled: root.fiatInputInteractive + anchors.fill: parent + cursorShape: enabled ? Qt.PointingHandCursor : undefined + enabled: root.fiatInputInteractive - onClicked: { - const secondaryValue = d.secondaryValue + onClicked: { + const secondaryValue = d.secondaryValue - d.fiatMode = !d.fiatMode + d.fiatMode = !d.fiatMode - if (textField.length === 0) - return + if (textField.length === 0) + return - const decimalPlaces = d.fiatMode ? root.fiatDecimalPlaces - : root.multiplierIndex - const divisor = SQUtils.AmountsArithmetic.fromExponent( - decimalPlaces) + const decimalPlaces = d.fiatMode ? root.fiatDecimalPlaces + : root.multiplierIndex + const divisor = SQUtils.AmountsArithmetic.fromExponent( + decimalPlaces) - const stringNumber = SQUtils.AmountsArithmetic.div( - SQUtils.AmountsArithmetic.fromString(secondaryValue), - divisor).toFixed(decimalPlaces) + const stringNumber = SQUtils.AmountsArithmetic.div( + SQUtils.AmountsArithmetic.fromString(secondaryValue), + divisor).toFixed(decimalPlaces) - const trimmed = d.fiatMode - ? stringNumber - : d.removeDecimalTrailingZeros(stringNumber) + const trimmed = d.fiatMode + ? stringNumber + : d.removeDecimalTrailingZeros(stringNumber) - textField.text = d.localize(trimmed) + textField.text = d.localize(trimmed) + } } + + HoverHandler { id: hoverHandler } + + visible: !root.bottomTextLoading + } + StatusIcon { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + + icon: "swap" + rotation: 90 + color: Theme.palette.directColor5 + visible: hoverHandler.hovered + } + Item { Layout.fillWidth: true } + Loader { + id: bottomRightComponent + Layout.alignment: Qt.AlignVCenter } - visible: !root.bottomTextLoading } LoadingComponent {