refactor(wallet): AssetsView on the terminal model — retire proxy chain

This commit is contained in:
Alex Jbanca
2026-07-27 18:50:27 +03:00
committed by Alex Jbanca
parent e0b6d5e3f1
commit 6b3eb84ffe
8 changed files with 518 additions and 613 deletions
-248
View File
@@ -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
+86 -35
View File
@@ -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}`)
+342 -30
View File
@@ -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 {
@@ -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"
+17 -37
View File
@@ -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 ?? "")
+63 -40
View File
@@ -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"
@@ -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
}
}
}
-1
View File
@@ -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