diff --git a/storybook/pages/AssetsViewAdaptorPage.qml b/storybook/pages/AssetsViewAdaptorPage.qml deleted file mode 100644 index 660bb56e8b..0000000000 --- a/storybook/pages/AssetsViewAdaptorPage.qml +++ /dev/null @@ -1,248 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import StatusQ.Models - -import Storybook - -import utils -import shared.views - -Item { - id: root - - ListModel { - id: listModel - - readonly property var data: [ - { - key: Constants.ethGroupKey, - name: "Ether", - symbol: "ETH", - balances: [ - { - chainId: "chain_id_1", - balance: "186316672770338050", - account: "account_1", - }, - { - chainId: "chain_id_1", - balance: "386318672772348050", - account: "account_2", - }, - { - chainId: "chain_id_2", - balance: "186311232772348990", - account: "account_1", - }, - { - chainId: "chain_id_2", - balance: "986317232772348990", - account: "account_1", - } - ], - decimals: 18, - communityId: "", - communityName: "", - communityImage: "", - marketDetails: { - changePct24hour: -2.1232, - currencyPrice: { - amount: 3423.23898 - } - }, - detailsLoading: false, - logoUri: "", - position: 1, - visible: true - }, - { - key: Constants.sntGroupKey, - name: "Status", - symbol: "SNT", - balances: [ - { - chainId: "chain_id_1", - balance: "386316672770338850", - account: "account_1", - }, - { - chainId: "chain_id_1", - balance: "377778672772348050", - account: "account_2", - }, - { - chainId: "chain_id_2", - balance: "146311232772348990", - account: "account_1", - }, - { - chainId: "chain_id_3", - balance: "86317232772348990", - account: "account_1", - } - ], - decimals: 18, - communityId: "", - communityName: "", - communityImage: "", - marketDetails: { - changePct24hour: 9.232, - currencyPrice: { - amount: 33.23898 - } - }, - detailsLoading: false, - logoUri: "", - position: 2, - visible: true - }, - { - key: "key_MYASST", - name: "Community Asset", - symbol: "MYASST", - balances: [ - { - chainId: "chain_id_1", - balance: "23234", - account: "account_1", - }, - { - chainId: "chain_id_1", - balance: "63234", - account: "account_2", - } - ], - decimals: 3, - communityId: "0x033f36ccb", - communityName: "My Community", - communityImage: Constants.tokenIcon("DAI", false), - marketDetails: { - changePct24hour: 0, - currencyPrice: { - amount: 0 - } - }, - detailsLoading: false, - logoUri: Constants.tokenIcon("ZRX", false), - position: 5, - visible: true - } - ] - - Component.onCompleted: { - append(data) - - const chains = new Set() - const accounts = new Set() - - data.forEach(e => e.balances.forEach( - e => { chains.add(e.chainId); - accounts.add(e.account)})) - - chainsSelector.model = [...chains.values()] - chainsDownSelector.model = [...chains.values()] - accountsSelector.model = [...accounts.values()] - } - } - - AssetsViewAdaptor { - id: adaptor - - chains: chainsSelector.selection - accounts: accountsSelector.selection - - marketValueThreshold: minimumBalanceSlider.value - - chainsError: chains => { - const chainsDown = chainsDownSelector.selection - const downForToken = chains.filter(value => chainsDown.includes(value)) - - if (downForToken.length) - return "Chains down: " + JSON.stringify(downForToken) - - return "" - } - - tokensModel: listModel - } - - ColumnLayout { - anchors.fill: parent - - Label { text: "CHAINS:" } - - CheckBoxFlowSelector { - id: chainsSelector - - Layout.fillWidth: true - initialSelection: true - } - - Label { text: "CHAINS DOWN:" } - - CheckBoxFlowSelector { - id: chainsDownSelector - - Layout.fillWidth: true - } - - Label { text: "ACCOUNTS:" } - - CheckBoxFlowSelector { - id: accountsSelector - - Layout.fillWidth: true - initialSelection: true - } - - Label { text: "MINIMUM BALANCE:" } - - RowLayout { - Slider { - id: minimumBalanceSlider - - from: 0.1 - to: 100 - - value: 10 - } - Label { - text: minimumBalanceSlider.value - } - } - - RowLayout { - GenericListView { - label: "Input model" - - model: listModel - - Layout.fillWidth: true - Layout.fillHeight: true - - skipEmptyRoles: true - } - - GenericListView { - label: "Adapter's output model" - - model: adaptor.model - - Layout.fillWidth: true - Layout.fillHeight: true - - roles: - ["key", "symbol", "name", "icon", "error", "balance", "balanceText", - "marketDetailsAvailable", "marketDetailsLoading", - "marketPrice", "marketChangePct24hour", "communityId", - "communityName", "communityIcon", "position", "canBeHidden"] - - skipEmptyRoles: true - } - } - } -} - -// category: Adaptors diff --git a/storybook/pages/AssetsViewPage.qml b/storybook/pages/AssetsViewPage.qml index 5cba207f63..1516dbd3c5 100644 --- a/storybook/pages/AssetsViewPage.qml +++ b/storybook/pages/AssetsViewPage.qml @@ -12,28 +12,72 @@ import Storybook import AppLayouts.Wallet.controls import AppLayouts.Wallet.panels +import StatusQ import StatusQ.Popups.Dialog +import SortFilterProxyModel + SplitView { id: root - ListModel { + function format(amount, symbol) { + return `${amount.toLocaleString(Qt.locale())} ${symbol}` + } + + // Role-compatible stub for the terminal AssetsAdaptorModel: derives + // isCommunity/marketBalance/change1DayFiat, filters to visible rows and + // re-sorts in place via sortBy(roleName, order) — mirrors the Nim model. + SortFilterProxyModel { id: assetsModel - function format(amount, symbol) { - return `${amount.toLocaleString(Qt.locale())} ${symbol}` + property int sortRoleOrder: Qt.DescendingOrder + property string sortRoleName: "name" + + function sortBy(roleName, order) { + assetsModel.sortRoleName = roleName + assetsModel.sortRoleOrder = order } + sourceModel: baseAssetsModel + proxyRoles: [ + FastExpressionRole { + name: "isCommunity" + expression: !!model.communityId ? "community" : "" + expectedRoles: ["communityId"] + }, + FastExpressionRole { + name: "marketBalance" + expression: model.balance * model.marketPrice + expectedRoles: ["balance", "marketPrice"] + }, + FastExpressionRole { + name: "change1DayFiat" + expression: model.marketBalance * (1 - (1 / (model.marketChangePct24hour / 100 + 1))) + expectedRoles: ["marketBalance", "marketChangePct24hour"] + } + ] + filters: ValueFilter { roleName: "visible"; value: true } + sorters: [ + RoleSorter { roleName: "isCommunity" }, + RoleSorter { + roleName: assetsModel.sortRoleName + sortOrder: assetsModel.sortRoleOrder + } + ] + } + + ListModel { + id: baseAssetsModel + Component.onCompleted: { const data = [ { key: "key_ETH", symbol: "ETH", name: "Ether", - icon: Constants.tokenIcon("ETH", false), + logoUri: Constants.tokenIcon("ETH", false), balance: 10.0, - balanceText: format(10.0, "ETH"), - error: "", + balanceLoading: false, marketDetailsAvailable: true, marketDetailsLoading: true, @@ -42,19 +86,20 @@ SplitView { communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), + communityImage: Qt.resolvedUrl(""), position: 2, - canBeHidden: false + canBeHidden: false, + visible: true, + chainIds: "1" }, { key: "key_SNT", symbol: "SNT", name: "Status", - icon: Constants.tokenIcon("SNT", false), + logoUri: Constants.tokenIcon("SNT", false), balance: 20023.0, - balanceText: format(20023.0, "SNT"), - error: "", + balanceLoading: false, marketDetailsAvailable: true, marketDetailsLoading: false, @@ -63,19 +108,20 @@ SplitView { communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), + communityImage: Qt.resolvedUrl(""), position: 1, - canBeHidden: true + canBeHidden: true, + visible: true, + chainIds: "1,10" }, { key: "key_MCT", symbol: "MCT", name: "My custom token", - icon: Constants.tokenIcon("ZRX", false), + logoUri: Constants.tokenIcon("ZRX", false), balance: 102.4, - balanceText: format(102.4, "MCT"), - error: "", + balanceLoading: false, marketDetailsAvailable: false, marketDetailsLoading: false, @@ -84,19 +130,20 @@ SplitView { communityId: "34", communityName: "Crypto Kitties", - communityIcon: Constants.tokenIcon("DAI", false), + communityImage: Constants.tokenIcon("DAI", false), position: 4, - canBeHidden: true + canBeHidden: true, + visible: true, + chainIds: "1" }, { key: "key_DAI", symbol: "DAI", name: "Dai", - icon: Constants.tokenIcon("DAI", false), + logoUri: Constants.tokenIcon("DAI", false), balance: 123.24, - balanceText: format(123.24, "DAI"), - error: "", + balanceLoading: false, marketDetailsAvailable: true, marketDetailsLoading: false, @@ -105,19 +152,20 @@ SplitView { communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), + communityImage: Qt.resolvedUrl(""), position: 3, - canBeHidden: true + canBeHidden: true, + visible: true, + chainIds: "1" }, { key: "key_USDT", symbol: "USDT", name: "USDT", - icon: Constants.tokenIcon("USDT", false), + logoUri: Constants.tokenIcon("USDT", false), balance: 15.24, - balanceText: format(15.24, "USDT"), - error: "", + balanceLoading: false, marketDetailsAvailable: true, marketDetailsLoading: false, @@ -126,21 +174,20 @@ SplitView { communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), + communityImage: Qt.resolvedUrl(""), position: 5, - canBeHidden: true + canBeHidden: true, + visible: true, + chainIds: "1" }, { key: "key_TBT", symbol: "TBT", name: "The best token", - icon: Constants.tokenIcon("UNI", false), + logoUri: Constants.tokenIcon("UNI", false), balance: 102, - balanceText: format(102, "TBT"), - error: "Pocket Network (POKT) & Infura are currently both " - + "unavailable for %1. %1 balances are as of %2." - .arg("TBT").arg("10/06/2024"), + balanceLoading: false, marketDetailsAvailable: false, marketDetailsLoading: false, @@ -149,10 +196,12 @@ SplitView { communityId: "3423", communityName: "Best tokens", - communityIcon: Constants.tokenIcon("UNI", false), + communityImage: Constants.tokenIcon("UNI", false), position: 6, - canBeHidden: true + canBeHidden: true, + visible: true, + chainIds: "1,10" } ] @@ -190,6 +239,8 @@ SplitView { ? "Market data error!" : "" model: assetsModel + formatBalance: (balance, key) => root.format(balance, key) + onSortRequested: (roleName, order) => assetsModel.sortBy(roleName, order) onSendRequested: (key) =>logs.logEvent(`send requested: ${key}`) onReceiveRequested: (key) => logs.logEvent(`receive requested: ${key}`) diff --git a/storybook/qmlTests/tests/tst_AssetsView.qml b/storybook/qmlTests/tests/tst_AssetsView.qml index 360d072742..cadb44ccde 100644 --- a/storybook/qmlTests/tests/tst_AssetsView.qml +++ b/storybook/qmlTests/tests/tst_AssetsView.qml @@ -2,16 +2,17 @@ import QtCore import QtQuick import QtTest -import StatusQ -import StatusQ.Models - import shared.views import AppLayouts.Wallet.panels import AppLayouts.Wallet.controls import utils + +import StatusQ +import StatusQ.Models import QtModelsToolkit import SortFilterProxyModel + Item { id: root width: 600 @@ -36,13 +37,218 @@ Item { "Wrapped Ether", "Status Test Token", "Ether", "Dai Stablecoin" ] - ListModel { + // Role-compatible stub for the terminal AssetsAdaptorModel: derives + // isCommunity/marketBalance/change1DayFiat, filters to visible rows and + // re-sorts in place via sortBy(roleName, order) — mirrors the Nim model. + SortFilterProxyModel { id: assetsModel - function formatBalance(amount, symbol) { - return amount.toLocaleCurrencyString(Qt.locale(), symbol) + property int sortRoleOrder: Qt.DescendingOrder + property string sortRoleName: "name" + + function sortBy(roleName, order) { + assetsModel.sortRoleName = roleName + assetsModel.sortRoleOrder = order } + sourceModel: baseAssetsModel + proxyRoles: [ + FastExpressionRole { + name: "isCommunity" + expression: !!model.communityId ? "community" : "" + expectedRoles: ["communityId"] + }, + FastExpressionRole { + name: "marketBalance" + expression: model.balance * model.marketPrice + expectedRoles: ["balance", "marketPrice"] + }, + FastExpressionRole { + name: "change1DayFiat" + expression: model.marketBalance * (1 - (1 / (model.marketChangePct24hour / 100 + 1))) + expectedRoles: ["marketBalance", "marketChangePct24hour"] + } + ] + filters: ValueFilter { roleName: "visible"; value: true } + sorters: [ + RoleSorter { roleName: "isCommunity" }, + RoleSorter { + roleName: assetsModel.sortRoleName + sortOrder: assetsModel.sortRoleOrder + } + ] + } + + ListModel { + id: baseAssetsModel + + Component.onCompleted: { + append([ + { + key: "key_DAI", + symbol: "DAI", + name: "Dai Stablecoin", + logoUri: Constants.tokenIcon("DAI", false), + balance: 1.0, + balanceLoading: false, + marketDetailsAvailable: true, + marketDetailsLoading: false, + marketPrice: 3.0, + marketChangePct24hour: 5.0, + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + position: 1, + canBeHidden: true, + visible: true, + chainIds: "1" + }, + { + key: "key_STT", + symbol: "STT", + name: "Status Test Token", + logoUri: Constants.tokenIcon("STT", false), + balance: 2.0, + balanceLoading: false, + marketDetailsAvailable: true, + marketDetailsLoading: false, + marketPrice: 2.0, + marketChangePct24hour: 5.0, + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + position: 2, + canBeHidden: true, + visible: true, + chainIds: "1" + }, + { + key: "key_WETH", + symbol: "WETH", + name: "Wrapped Ether", + logoUri: Constants.tokenIcon("ETH", false), + balance: 3.0, + balanceLoading: false, + marketDetailsAvailable: true, + marketDetailsLoading: false, + marketPrice: 3.1, + marketChangePct24hour: 5.0, + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + position: 3, + canBeHidden: true, + visible: true, + chainIds: "1" + }, + { + key: "key_ETH", + symbol: "ETH", + name: "Ether", + logoUri: Constants.tokenIcon("ETH", false), + balance: 4.0, + balanceLoading: false, + marketDetailsAvailable: true, + marketDetailsLoading: false, + marketPrice: 4.1, + marketChangePct24hour: 5.0, + communityId: "", + communityName: "", + communityImage: Qt.resolvedUrl(""), + position: 4, + canBeHidden: false, + visible: true, + chainIds: "1" + } + ]) + } + } + + Component { + id: assetsViewComponent + AssetsView { + width: root.width + height: root.height + sorterVisible: true + model: assetsModel + onSortRequested: (roleName, order) => assetsModel.sortBy(roleName, order) + } + } + + // A source model that starts empty; rows are appended during the test to + // reproduce the production timing where token rows arrive asynchronously, + // after the view is already up in its `loading` state. + ListModel { + id: asyncBaseModel + } + + SortFilterProxyModel { + id: asyncAssetsModel + + property int sortRoleOrder: Qt.DescendingOrder + property string sortRoleName: "name" + + function sortBy(roleName, order) { + asyncAssetsModel.sortRoleName = roleName + asyncAssetsModel.sortRoleOrder = order + } + + sourceModel: asyncBaseModel + proxyRoles: [ + FastExpressionRole { + name: "isCommunity" + expression: !!model.communityId ? "community" : "" + expectedRoles: ["communityId"] + }, + FastExpressionRole { + name: "marketBalance" + expression: model.balance * model.marketPrice + expectedRoles: ["balance", "marketPrice"] + }, + FastExpressionRole { + name: "change1DayFiat" + expression: model.marketBalance * (1 - (1 / (model.marketChangePct24hour / 100 + 1))) + expectedRoles: ["marketBalance", "marketChangePct24hour"] + } + ] + filters: ValueFilter { roleName: "visible"; value: true } + sorters: [ + RoleSorter { roleName: "isCommunity" }, + RoleSorter { + roleName: asyncAssetsModel.sortRoleName + sortOrder: asyncAssetsModel.sortRoleOrder + } + ] + } + + Component { + id: asyncAssetsViewComponent + AssetsView { + width: root.width + height: root.height + sorterVisible: true + model: asyncAssetsModel + onSortRequested: (roleName, order) => asyncAssetsModel.sortBy(roleName, order) + } + } + + Component { + id: modelChangedSpyComponent + SignalSpy { signalName: "modelChanged" } + } + + // ----- Custom-ordering harness ------------------------------------------- + // Ported from the upstream custom-ordering tests (commit 50c24691dc) and + // adapted to the terminal-model architecture: our AssetsView no longer + // sorts internally, it emits sortRequested(roleName, order) and the + // consumer sorts the model. The harness therefore wires onSortRequested to + // a sortBy stub (mirroring RootStore.walletAssetsStore.sortAssets in + // production) and gives assetsViewModel a RoleSorter driven by the + // requested role. The "position" role is supplied by the controller, so a + // TokenOrderCustom sort reorders the wallet list into the saved order. + ListModel { + id: customOrderAssetsModel + function marketDetailsForPrice(price) { return { currencyPrice: { @@ -62,7 +268,6 @@ Item { name: "Dai Stablecoin", logoUri: Constants.tokenIcon("DAI", false), balance: 1.0, - balanceText: formatBalance(1.0, "DAI"), balanceLoading: false, error: "", decimals: 18, @@ -73,8 +278,10 @@ Item { marketChangePct24hour: 5.0, communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), - canBeHidden: true + communityImage: Qt.resolvedUrl(""), + canBeHidden: true, + visible: true, + chainIds: "1" }, { key: "key_STT", @@ -82,7 +289,6 @@ Item { name: "Status Test Token", logoUri: Constants.tokenIcon("STT", false), balance: 2.0, - balanceText: formatBalance(2.0, "STT"), balanceLoading: false, error: "", decimals: 18, @@ -93,8 +299,10 @@ Item { marketChangePct24hour: 5.0, communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), - canBeHidden: true + communityImage: Qt.resolvedUrl(""), + canBeHidden: true, + visible: true, + chainIds: "1" }, { key: "key_WETH", @@ -102,7 +310,6 @@ Item { name: "Wrapped Ether", logoUri: Constants.tokenIcon("ETH", false), balance: 3.0, - balanceText: formatBalance(3.0, "WETH"), balanceLoading: false, error: "", decimals: 18, @@ -113,8 +320,10 @@ Item { marketChangePct24hour: 5.0, communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), - canBeHidden: true + communityImage: Qt.resolvedUrl(""), + canBeHidden: true, + visible: true, + chainIds: "1" }, { key: "key_ETH", @@ -122,7 +331,6 @@ Item { name: "Ether", logoUri: Constants.tokenIcon("ETH", false), balance: 4.0, - balanceText: formatBalance(4.0, "ETH"), balanceLoading: false, error: "", decimals: 18, @@ -133,8 +341,10 @@ Item { marketChangePct24hour: 5.0, communityId: "", communityName: "", - communityIcon: Qt.resolvedUrl(""), - canBeHidden: false + communityImage: Qt.resolvedUrl(""), + canBeHidden: false, + visible: true, + chainIds: "1" } ]) } @@ -161,7 +371,7 @@ Item { ManageTokensController { id: assetsController - sourceModel: assetsModel + sourceModel: customOrderAssetsModel settingsKey: "AssetsViewCustomOrderTest" serializeAsCollectibles: false @@ -179,7 +389,18 @@ Item { SortFilterProxyModel { id: assetsViewModel - sourceModel: assetsModel + + // Terminal-model adaptation: our AssetsView asks the consumer to sort; + // sortBy(roleName, order) re-sorts this proxy in place. + property int sortRoleOrder: Qt.DescendingOrder + property string sortRoleName: "balance" + + function sortBy(roleName, order) { + assetsViewModel.sortRoleName = roleName + assetsViewModel.sortRoleOrder = order + } + + sourceModel: customOrderAssetsModel proxyRoles: [ FastExpressionRole { name: "position" @@ -190,6 +411,12 @@ Item { expectedRoles: ["key"] } ] + sorters: [ + RoleSorter { + roleName: assetsViewModel.sortRoleName + sortOrder: assetsViewModel.sortRoleOrder + } + ] } QtObject { @@ -198,16 +425,6 @@ Item { property string screen: "settings" } - Component { - id: assetsViewComponent - AssetsView { - width: root.width - height: root.height - sorterVisible: true - model: assetsModel - } - } - Component { id: customOrderingHarnessComponent Item { @@ -224,6 +441,7 @@ Item { model: assetsViewModel customOrderAvailable: assetsController.hasSettings onManageTokensRequested: flowState.screen = "manageTokens" + onSortRequested: (roleName, order) => assetsViewModel.sortBy(roleName, order) function refreshSortSettings() { let value = SortOrderComboBox.TokenOrderBalance @@ -368,6 +586,100 @@ Item { function test_sortByUi_asc_desc(data) { verifySortByUi(data.optionText, data.orderAsc, data.orderDesc) } + + // A periodic market/balance refresh toggles `loading` while real data is + // already present. Once the regular model has content the list must stay + // bound to it — swapping to the loading placeholder tears down and + // recreates every delegate on each refresh. + function test_loadingToggle_keepsPopulatedModel() { + const listView = getListView(controlUnderTest) + waitForRendering(listView) + compare(listView.count, 4) + const modelInstance = listView.model + + controlUnderTest.loading = true + verify(listView.model === modelInstance, + "loading placeholder must not replace the populated model on refresh") + compare(listView.count, 4) + + controlUnderTest.loading = false + verify(listView.model === modelInstance) + compare(listView.count, 4) + } + + // Once data is present, toggling `loading` must not re-assign the list's + // model at all. Keeping the same model instance is not enough: on the + // production ListView, re-assigning even the identical DelegateModel makes + // the view rebuild every delegate. The model binding must therefore drop + // `loading` from its dependencies once the content latch is set, so the + // periodic refresh toggles never re-evaluate it. + function test_loadingToggle_doesNotReassignModel() { + const listView = getListView(controlUnderTest) + waitForRendering(listView) + compare(listView.count, 4) // regular model, data present + + const spy = modelChangedSpyComponent.createObject(root, { target: listView }) + verify(spy.valid) + spy.clear() + + for (let i = 0; i < 4; ++i) { + controlUnderTest.loading = (i % 2 === 0) + waitForRendering(listView) + } + + compare(spy.count, 0, + "loading toggles must not re-assign the list model once data is present") + spy.destroy() + } + + // Covers the production initial condition the sort test above does not: + // the view starts in the global `loading` state (list on the placeholder) + // and token rows arrive asynchronously afterwards. Once real data has + // arrived the list must switch to and stay on the regular model across + // the periodic `loading` toggles, never rebuilding against the + // placeholder. NOTE: the on-device regression this guards is driven by + // the C++ terminal model not reporting its rows until a view consumes + // the regular DelegateModel; QML ListModel/SortFilterProxyModel report + // rows eagerly, so this case cannot fully reproduce that timing here — + // the authoritative red/green for it is the on-device startup trace. + function test_loadingStartTrue_latchesFromSourceModel() { + asyncBaseModel.clear() + const view = createTemporaryObject(asyncAssetsViewComponent, root, + { loading: true }) + verify(!!view) + const listView = getListView(view) + waitForRendering(listView) + + // Rows arrive while still loading and while the list is on the + // placeholder (the regular DelegateModel is not consumed yet). + asyncBaseModel.append({ + key: "key_ETH", symbol: "ETH", name: "Ether", + logoUri: Constants.tokenIcon("ETH", false), + balance: 4.0, balanceLoading: false, + marketDetailsAvailable: true, marketDetailsLoading: false, + marketPrice: 4.1, marketChangePct24hour: 5.0, + communityId: "", communityName: "", + communityImage: Qt.resolvedUrl(""), + position: 1, canBeHidden: false, visible: true, chainIds: "1" + }) + waitForRendering(listView) + + // First real data has arrived: switch to the regular model and stay + // there across the periodic loading toggle. + view.loading = false + waitForRendering(listView) + const populatedModel = listView.model + compare(listView.count, 1) + + view.loading = true + verify(listView.model === populatedModel, + "loading placeholder must not replace the populated model after first data") + compare(listView.count, 1) + + view.loading = false + verify(listView.model === populatedModel) + compare(listView.count, 1) + } } TestCase { diff --git a/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml b/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml index c9b69bea6d..019b2ba543 100644 --- a/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml +++ b/ui/app/AppLayouts/Wallet/stores/WalletAssetsStore.qml @@ -32,6 +32,16 @@ QtObject { */ readonly property var baseGroupedAccountAssetModel: walletSectionAssets.groupedAccountAssetsModel + // Terminal, already-aggregated assets model built in Nim (replaces the QML + // AssetsViewAdaptor proxy chain). Consumed by AssetsView in RightTabView. + readonly property var assetsModel: walletSectionAssetsView.assetsModel + + // Re-sorts the terminal assets model in place. AssetsView emits the sort + // intent; this wires it to the model. + function sortAssets(roleName, order) { + walletSectionAssetsView.sortBy(roleName, order) + } + readonly property var assetsController: ManageTokensController { sourceModel: groupedAccountAssetsModel settingsKey: "WalletAssets" diff --git a/ui/app/AppLayouts/Wallet/views/RightTabView.qml b/ui/app/AppLayouts/Wallet/views/RightTabView.qml index 0e9b0ae8ac..b29241d429 100644 --- a/ui/app/AppLayouts/Wallet/views/RightTabView.qml +++ b/ui/app/AppLayouts/Wallet/views/RightTabView.qml @@ -305,39 +305,19 @@ RightTabBaseView { AssetsView { id: walletAssetsView - AssetsViewAdaptor { - id: assetsViewAdaptor - - accounts: RootStore.addressFilters - chains: root.networksStore.networkFilters - - marketValueThreshold: - RootStore.tokensStore.displayAssetsBelowBalance - ? RootStore.tokensStore.getDisplayAssetsBelowBalanceThresholdDisplayAmount() - : 0 - - Connections { - target: RootStore.tokensStore - - function displayAssetsBelowBalanceThresholdChanged() { - assetsViewAdaptor.marketValueThresholdChanged() - } - } - - tokensModel: RootStore.walletAssetsStore.groupedAccountAssetsModel - - formatBalance: (balance, key) => { - return LocaleUtils.currencyAmountToLocaleString( - RootStore.currencyStore.getCurrencyAmount(balance, key)) - } - - chainsError: (chains) => { - if (!root.networkConnectionStore) - return "" - return root.networkConnectionStore.getBlockchainNetworkDownText(chains) - } + formatBalance: (balance, key) => { + return LocaleUtils.currencyAmountToLocaleString( + RootStore.currencyStore.getCurrencyAmount(balance, key)) } + chainsError: (chains) => { + if (!root.networkConnectionStore) + return "" + return root.networkConnectionStore.getBlockchainNetworkDownText(chains) + } + + onSortRequested: (roleName, order) => RootStore.walletAssetsStore.sortAssets(roleName, order) + function refreshSortSettings() { settings.category = settingsCategoryName walletSettings.sync() @@ -387,7 +367,7 @@ RightTabBaseView { loading: RootStore.overview.balanceLoading sorterVisible: filterButton.checked customOrderAvailable: RootStore.walletAssetsStore.assetsController.hasSettings - model: assetsViewAdaptor.model + model: RootStore.walletAssetsStore.assetsModel bannerComponent: buyReceiveBannerComponent marketDataError: !!root.networkConnectionStore @@ -422,15 +402,15 @@ RightTabBaseView { onCommunityClicked: Global.switchToCommunity(communityKey) onHideRequested: (key) => { - const token = SQUtils.ModelUtils.getByKey(model, "key", key) - Global.openConfirmHideAssetPopup(token.symbol, token.name, token.icon, !!token.communityId) + const token = SQUtils.ModelUtils.getByKey(RootStore.walletAssetsStore.groupedAccountAssetsModel, "key", key) + Global.openConfirmHideAssetPopup(token.symbol, token.name, token.logoUri, !!token.communityId) } onHideCommunityAssetsRequested: (communityKey) => { - const community = SQUtils.ModelUtils.getByKey(model, "communityId", communityKey) + const community = SQUtils.ModelUtils.getByKey(RootStore.walletAssetsStore.groupedAccountAssetsModel, "communityId", communityKey) confirmHideCommunityAssetsPopup.createObject(root, { name: community.communityName, - icon: community.communityIcon, + icon: community.communityImage, communityId: communityKey } ).open() } @@ -439,7 +419,7 @@ RightTabBaseView { Constants.settingsSubsection.wallet, Constants.walletSettingsSubsection.manageAssets) onAssetClicked: (key) => { - const tokenGroup = SQUtils.ModelUtils.getByKey(model, "key", key) + const tokenGroup = SQUtils.ModelUtils.getByKey(RootStore.walletAssetsStore.groupedAccountAssetsModel, "key", key) assetDetailView.tokenGroup = tokenGroup RootStore.setCurrentViewedHolding(tokenGroup.key, Constants.TokenType.ERC20, tokenGroup.communityId ?? "") diff --git a/ui/imports/shared/views/AssetsView.qml b/ui/imports/shared/views/AssetsView.qml index 46147953e0..6250915aba 100644 --- a/ui/imports/shared/views/AssetsView.qml +++ b/ui/imports/shared/views/AssetsView.qml @@ -4,6 +4,8 @@ import QtQuick.Layouts import QtQml.Models +import QtModelsToolkit + import StatusQ import StatusQ.Core import StatusQ.Core.Theme @@ -14,13 +16,18 @@ import shared.controls import shared.popups import utils -import SortFilterProxyModel - - Control { id: root /** + Model contract (terminal model — already visible-filtered and sorted; no + proxy is placed above it here): + - provides the derived roles `isCommunity`, `marketBalance`, + `change1DayFiat` and a `chainIds` role (comma-separated chain ids) + in addition to the base token roles below; + - re-sorts in place via `sortBy(roleName, order)` — this view emits the + `sortRequested` intent, the consumer wires it to the model. + Expected model structure: key [string] - refers to token group key @@ -118,6 +125,17 @@ Control { // formatting function for fiat currency values property var formatFiat: balance => `${balance.toLocaleCurrencyString(Qt.locale())}` + // formats a token's balance for display (moved out of the retired proxy chain; + // the consumer supplies the currency context) + property var formatBalance: (balance, key) => `${balance.toLocaleString(Qt.locale())} ${key}` + + // returns an error message for a token given its contributing chain ids, + // or an empty string when there is none + property var chainsError: (chainIds) => "" + + // sort intent — consumer wires this to the model's sortBy(roleName, order) + signal sortRequested(string roleName, int order) + signal sendRequested(string key) signal receiveRequested(string key) signal swapRequested(string key) @@ -149,41 +167,33 @@ Control { readonly property int loadingItemsCount: 25 property int sortOrder: Qt.DescendingOrder property int sortValue: -1 + + // Latched true once the source model has ever had rows. Keeps the list + // bound to the real model across periodic refreshes that toggle + // `loading`; the placeholder only ever shows before the first real data. + property bool everHadContent: false + // Latch off the source model's row count. Guard the null case with `&&` + // rather than optional chaining: the AOT-compiled mobile build drops the + // reactive dependency captured through `?.`, so the latch would never + // re-evaluate when rows arrive after creation. + readonly property int modelCount: !!root.model ? root.model.ModelCount.count : 0 + onModelCountChanged: if (modelCount > 0) everHadContent = true + Component.onCompleted: if (modelCount > 0) everHadContent = true + + // Emit the current sorter selection as an intent; separators carry an + // empty role name and are ignored. + function requestSort() { + const roleName = sortOrderComboBox.currentSortRoleName + if (!roleName) + return + root.sortRequested(roleName, sortOrderComboBox.currentSortOrder) + } } - SortFilterProxyModel { - id: sfpm - - sourceModel: root.model ?? null - - proxyRoles: [ - // helper role for rendering section delegate - FastExpressionRole { - name: "isCommunity" - expression: !!model.communityId ? "community" : "" - expectedRoles: ["communityId"] - }, - FastExpressionRole { - name: "marketBalance" - expression: model.balance * model.marketPrice - expectedRoles: ["balance", "marketPrice"] - }, - FastExpressionRole { - name: "change1DayFiat" - expression: model.marketBalance * (1 - (1 / (model.marketChangePct24hour / 100 + 1))) - expectedRoles: ["marketBalance", "marketChangePct24hour"] - } - ] - - sorters: [ - RoleSorter { - roleName: "isCommunity" - }, - RoleSorter { - roleName: sortOrderComboBox.currentSortRoleName - sortOrder: sortOrderComboBox.currentSortOrder - } - ] + Connections { + target: sortOrderComboBox + function onCurrentSortRoleNameChanged() { d.requestSort() } + function onCurrentSortOrderChanged() { d.requestSort() } } contentItem: ColumnLayout { @@ -273,16 +283,22 @@ Control { DelegateModel { id: regularModel - model: sfpm + model: root.model ?? null delegate: TokenDelegate { objectName: `AssetView_TokenListItem_${model.symbol}` // TODO: use model.key width: ListView.view.width + // chainIds arrives as a comma-separated string from the terminal model + readonly property var chainIdsList: { + const ids = model.chainIds + return (ids && ids.length) ? ids.split(",").map(Number) : [] + } + name: model.name icon: model.logoUri - balance: model.balanceText + balance: root.formatBalance(model.balance, model.key) balanceLoading: model.balanceLoading marketBalance: root.formatFiat(model.marketBalance) @@ -295,7 +311,7 @@ Control { communityName: model.communityName ?? "" communityIcon: model.communityImage ?? "" - errorTooltipText_1: model.error + errorTooltipText_1: root.chainsError(chainIdsList) errorTooltipText_2: root.marketDataError errorMode: !!root.balanceError @@ -332,7 +348,14 @@ Control { Layout.fillWidth: true Layout.fillHeight: true - model: root.loading ? loadingModel : regularModel + // Operand order matters: once `everHadContent` latches true, + // `!d.everHadContent` is false and short-circuits `&&`, dropping + // `root.loading` from this binding's captured dependencies. The + // periodic `loading` toggles then no longer re-evaluate and re-assign + // the model — re-assigning even the same DelegateModel makes the view + // rebuild every delegate. Before first data it still shows the + // placeholder while `loading` is true. + model: (!d.everHadContent && root.loading) ? loadingModel : regularModel section { property: "isCommunity" diff --git a/ui/imports/shared/views/AssetsViewAdaptor.qml b/ui/imports/shared/views/AssetsViewAdaptor.qml deleted file mode 100644 index f86658f768..0000000000 --- a/ui/imports/shared/views/AssetsViewAdaptor.qml +++ /dev/null @@ -1,222 +0,0 @@ -import QtQml - -import StatusQ -import StatusQ.Core.Utils - -import utils - -import QtModelsToolkit -import SortFilterProxyModel - -QObject { - id: root - - /** - Expected model structure: - - Tokens related part: - - tokensKey [string] - unique identifier of a token, e.g "0x3234235" - symbol [string] - token's symbol e.g. "ETH" or "SNT" - name [string] - token's name e.g. "Ether" or "Dai" - image [url] - token's icon for custom tokens - decimals [int] - number of decimal places, e.g. 18 for ETH - balances [model] - submodel of balances per chain/account - chainId [int] - unique identifier of a chain - account [string] - unique identifier of an account - balance [string] - balance in basic unit as big integer string - marketDetails [object] - object holding market details - changePct24hour [double] - percentage change of fiat price in last day - currencyPrice [object] - object holding fiat price details - amount [double] - fiat prace of 1 logical unit of cryptocurrency - detailsLoading [bool] - indicatator if market details are ready to use - position [int] - custom order position - visible [bool] - token management visiblity flag - - Community related part (relevant for community minted assets, empty otherwise): - - communityId [string] - unique identifier of a community, e.g. "0x6734235" - communityName [string] - name of a community e.g. "Crypto Kitties" - communityImage [url] - community's icon url - **/ - property var tokensModel - - // function formatting tokens balance expressed in a commonly used units, - // e.g. 1.2 for 1.2 ETH, according to rules specific for given symbol - property var formatBalance: - (balance, key) => `${balance.toLocaleString(Qt.locale())} ${key}` - - // function providing error message per token depending on used chains, - // should return empty string if no error found - property var chainsError: chains => "" - - // array[Number]list of chain identifiers used for balance calculation - property var chains: [] - - // list of accounts used for balance calculation - property var accounts: [] - - // threshold below which the token is omitted from the output model - property double marketValueThreshold - - /** - Model structure: - - All roles from the source model are passed directly to the output model, - additionally: - - key [string] - refers to token group key - icon [url] - from image or fetched by symbol for well-known tokens - balance [double] - tokens balance is the commonly used unit, e.g. 1.2 for 1.2 ETH, - computed from balances according to provided criteria - balanceText [string] - formatted and localized balance - balanceLoading [bool] - true while any per-(chain, account) balance matching the - active filter has not been fetched yet from status-go - error [string] - error message related to balance - - marketDetailsAvailable [bool] - specifies if market datails are available for given token - marketDetailsLoading [bool] - specifies if market datails are available for given token - marketPrice [double] - specifies market price in currently used currency - marketChangePct24hour [double] - percentage price change in last 24 hours, e.g. 0.5 for 0.5% of price change - - canBeHidden [bool] - specifies if given token can be hidden (e.g. ETH should be always visible) - - communityIcon [url] - renamed from communityImage - **/ - readonly property alias model: sfpm - - ObjectProxyModel { - id: proxyModel - - objectName: "assetsViewAdaptorProxyModel" - - sourceModel: root.tokensModel ?? null - - delegate: QObject { - readonly property var rootModel: model - readonly property bool hasCommunityId: !!model.communityId - readonly property var marketDetails: model.marketDetails - - // Read-only roles exposed to the model: - - readonly property string error: - root.chainsError(chainsAggregator.uniqueChains) - - readonly property double balance: - AmountsArithmetic.toNumber(totalBalanceAggregator.value, model.decimals) - readonly property string balanceText: root.formatBalance(balance, model.key) - readonly property bool balanceLoading: loadingBalancesAggregator.value > 0 - - readonly property bool marketDetailsAvailable: !hasCommunityId - readonly property bool marketDetailsLoading: model.detailsLoading - readonly property real marketPrice: marketDetails?.currencyPrice?.amount ?? 0 - readonly property real marketChangePct24hour: marketDetails?.changePct24hour ?? 0 - - readonly property bool visible: { - if (!model.visible) - return false - - if (filteredBalances.ModelCount.empty) - return false - - if (hasCommunityId) - return true - - // Keep mandatory/known tokens visible while their balances are still being fetched, - // so the user sees them in a loading state instead of having an empty Assets tab. - if (balanceLoading) - return true - - return balance * marketPrice >= root.marketValueThreshold - } - - readonly property url icon: - !!model.logoUri ? model.logoUri - : Constants.tokenIcon(model.symbol, false) - - readonly property url communityIcon: model.communityImage ?? "" - - readonly property bool canBeHidden: { - for (const chain of root.chains) { - if (model.symbol === Utils.getNativeTokenSymbol(chain)) { - return false - } - } - return true - } - - SortFilterProxyModel { - id: filteredBalances - - sourceModel: rootModel.balances - - filters: [ - OneOfFilter { - roleName: "chainId" - array: root.chains - separator: ":" - }, - OneOfFilter { - roleName: "account" - array: root.accounts - separator: ":" - } - ] - } - - FunctionAggregator { - id: totalBalanceAggregator - - model: filteredBalances - initialValue: "0" - roleName: "balance" - - aggregateFunction: (aggr, value) => AmountsArithmetic.sum( - AmountsArithmetic.fromString(aggr), - AmountsArithmetic.fromString(value)).toString() - } - - FunctionAggregator { - id: chainsAggregator - - readonly property var uniqueChains: [...new Set(value).values()] - - model: filteredBalances - initialValue: [] - roleName: "chainId" - - aggregateFunction: (aggr, value) => [...aggr, value] - } - - FunctionAggregator { - id: loadingBalancesAggregator - - model: filteredBalances - initialValue: 0 - roleName: "loading" - - aggregateFunction: (aggr, value) => aggr + (value ? 1 : 0) - } - } - - expectedRoles: - ["key", "symbol", "logoUri", "balances", "decimals", - "detailsLoading", "marketDetails", "communityId", "communityImage", - "visible"] - exposedRoles: - ["error", "balance", "balanceText", "balanceLoading", "icon", - "visible", "canBeHidden", "marketDetailsAvailable", "marketDetailsLoading", - "marketPrice", "marketChangePct24hour", "communityIcon"] - } - - SortFilterProxyModel { - id: sfpm - - sourceModel: proxyModel - - filters: ValueFilter { - roleName: "visible" - value: true - } - } -} diff --git a/ui/imports/shared/views/qmldir b/ui/imports/shared/views/qmldir index e2cf430a65..a07d914753 100644 --- a/ui/imports/shared/views/qmldir +++ b/ui/imports/shared/views/qmldir @@ -1,6 +1,5 @@ AssetContextMenu 1.0 AssetContextMenu.qml AssetsView 1.0 AssetsView.qml -AssetsViewAdaptor 1.0 AssetsViewAdaptor.qml ConfirmHideAssetPopup 1.0 ConfirmHideAssetPopup.qml ConfirmHideCommunityAssetsPopup 1.0 ConfirmHideCommunityAssetsPopup.qml ExistingContacts 1.0 ExistingContacts.qml