From d0389a6305631bba7e45155990cc1353110c7d85 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 28 Oct 2022 20:17:16 +0300 Subject: [PATCH] feat(Wallet) show balance cache for chain native token Quick integration of fetching balance in the current chart view. The proper implementation requires refactoring the QML views to separate price chart, that depends only on the token and chain, from balance that depends on token, chain and address. Closes: #7662 --- .../src/StatusGo/Wallet/WalletApi.cpp | 6 +- .../src/StatusGo/Wallet/WalletApi.h | 14 +- libs/StatusQ/qml/Status/Core/Theme/Theme.qml | 2 +- .../wallet_section/all_tokens/controller.nim | 6 + .../all_tokens/historical_balance.md | 153 ------------------ .../all_tokens/io_interface.nim | 6 + .../main/wallet_section/all_tokens/module.nim | 7 + .../main/wallet_section/all_tokens/view.nim | 5 + src/app_service/service/token/async_tasks.nim | 41 +++++ src/app_service/service/token/service.nim | 37 ++++- src/backend/backend.nim | 7 +- test/libs/StatusGoQt/test_wallet.cpp | 44 +++-- .../sandbox/pages/StatusChartPanelPage.qml | 2 +- .../StatusQ/Components/StatusChartPanel.qml | 24 ++- .../Components/private/chart/Chart.qml | 7 + ui/app/AppLayouts/Wallet/WalletLayout.qml | 1 - .../AppLayouts/Wallet/views/RightTabView.qml | 3 + .../popups/keycard/helpers/KeycardItem.qml | 4 +- ui/imports/shared/stores/ChartStoreBase.qml | 126 +++++++++++++++ ui/imports/shared/stores/RootStore.qml | 5 + .../stores/TokenBalanceHistoryStore.qml | 89 ++++++++++ .../shared/stores/TokenMarketValuesStore.qml | 69 +------- ui/imports/shared/views/AssetsDetailView.qml | 104 ++++++++++-- 23 files changed, 504 insertions(+), 258 deletions(-) delete mode 100644 src/app/modules/main/wallet_section/all_tokens/historical_balance.md create mode 100644 ui/imports/shared/stores/ChartStoreBase.qml create mode 100644 ui/imports/shared/stores/TokenBalanceHistoryStore.qml diff --git a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp index 94c3a35b4..45b90f15a 100644 --- a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp +++ b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp @@ -109,10 +109,10 @@ TokenBalances getTokensBalancesForChainIDs(const std::vector& chainIds, } std::vector -getBalanceHistoryOnChain(Accounts::EOAddress account, const std::chrono::seconds& secondsToNow, int sampleCount) +getBalanceHistory(const ChainID& chainID, Accounts::EOAddress account, BalanceHistoryTimeInterval timeInterval) { - std::vector params = {account, secondsToNow.count(), sampleCount}; - json inputJson = {{"jsonrpc", "2.0"}, {"method", "wallet_getBalanceHistoryOnChain"}, {"params", params}}; + std::vector params = {chainID, account, timeInterval}; + json inputJson = {{"jsonrpc", "2.0"}, {"method", "wallet_getBalanceHistory"}, {"params", params}}; auto result = Utils::statusGoCallPrivateRPC(inputJson.dump().c_str()); const auto resultJson = json::parse(result); diff --git a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h index 5a671a603..780df3513 100644 --- a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h +++ b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h @@ -70,14 +70,24 @@ struct TokenBalanceHistory NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(TokenBalanceHistory, value, time) +/// @see status-go's services/wallet/transfer/controller.go BalanceHistoryTimeInterval +enum BalanceHistoryTimeInterval +{ + BalanceHistory7Hours = 1, + BalanceHistory1Month, + BalanceHistory6Months, + BalanceHistory1Year, + BalanceHistoryAllTime +}; + /// \warning it relies on the stored transaction data fetched by calling \c checkRecentHistory /// \todo reconsider \c checkRecentHistory dependency /// /// \see checkRecentHistory -/// \note status-go's API -> GetBalanceHistoryOnChain@api.go +/// \note status-go's API -> GetBalanceHistory@api.go /// \throws \c CallPrivateRpcError std::vector -getBalanceHistoryOnChain(Accounts::EOAddress account, const std::chrono::seconds& secondsToNow, int sampleCount); +getBalanceHistory(const ChainID& chainID, Accounts::EOAddress account, BalanceHistoryTimeInterval timeInterval); /// \note status-go's API -> CheckRecentHistory@api.go /// \throws \c CallPrivateRpcError diff --git a/libs/StatusQ/qml/Status/Core/Theme/Theme.qml b/libs/StatusQ/qml/Status/Core/Theme/Theme.qml index d3afc8c36..34c65265e 100644 --- a/libs/StatusQ/qml/Status/Core/Theme/Theme.qml +++ b/libs/StatusQ/qml/Status/Core/Theme/Theme.qml @@ -31,7 +31,7 @@ QtObject { current = isCurrentSystemThemeDark? darkTheme : lightTheme; break; default: - console.warning('Unknown theme. Valid themes are "light" and "dark"') + console.warn('Unknown theme. Valid themes are "light" and "dark"') } } } diff --git a/src/app/modules/main/wallet_section/all_tokens/controller.nim b/src/app/modules/main/wallet_section/all_tokens/controller.nim index 69fd44fe9..a42b2916a 100644 --- a/src/app/modules/main/wallet_section/all_tokens/controller.nim +++ b/src/app/modules/main/wallet_section/all_tokens/controller.nim @@ -40,6 +40,10 @@ proc init*(self: Controller) = let args = TokenHistoricalDataArgs(e) self.delegate.tokenHistoricalDataResolved(args.result) + self.events.on(SIGNAL_BALANCE_HISTORY_DATA_READY) do(e:Args): + let args = TokenBalanceHistoryDataArgs(e) + self.delegate.tokenBalanceHistoryDataResolved(args.result) + proc getTokens*(self: Controller): seq[token_service.TokenDto] = proc compare(x, y: token_service.TokenDto): int = if x.name < y.name: @@ -73,3 +77,5 @@ method findTokenSymbolByAddress*(self: Controller, address: string): string = method getHistoricalDataForToken*(self: Controller, symbol: string, currency: string, range: int) = self.tokenService.getHistoricalDataForToken(symbol, currency, range) +method fetchHistoricalBalanceForTokenAsJson*(self: Controller, address: string, symbol: string, timeIntervalEnum: int) = + self.tokenService.fetchHistoricalBalanceForTokenAsJson(address, symbol, BalanceHistoryTimeInterval(timeIntervalEnum)) \ No newline at end of file diff --git a/src/app/modules/main/wallet_section/all_tokens/historical_balance.md b/src/app/modules/main/wallet_section/all_tokens/historical_balance.md deleted file mode 100644 index 9244ee22e..000000000 --- a/src/app/modules/main/wallet_section/all_tokens/historical_balance.md +++ /dev/null @@ -1,153 +0,0 @@ -# Implement historical balance - -Task [#7662](https://github.com/status-im/status-desktop/issues/7662) - -## Summary - -User story: as a user I want to see the historical balance of a specific token in the Asset view Balance tab - -UI design [Figma](https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?node-id=6770%3A76490) - -## Considerations - -Technical requirements - -- New UI View - - Time interval tabs - - [ ] Save last selected tab? Which is the default? - - The price history uses All Time as default - - Show the graph for the selected tab -- New Controller fetch API entry and async Response -- `status-go` API call to fetch token historical balance - -### Assumptions - -Data source - -- The balance history is unique for each address and token. -- It is represented by the blockchain transactions (in/out) for a specific address and token -- The original data resides in blocks on blockchain as transactions -- Direct data fetch is not possible so using infura APIs that caches the data is the alternative -- We already fetch part of the data when displaying `Activity` as a list of transactions -- Constructing the data forward (from the block 0) is not practical. - - Given that we have to show increasing period of activity starting from 1H to All time best is to fetch the data backwards - - Start going from current balance to the past and inverse the operations while going back - - Q - - [ ] What information do we get with each transaction request? - -Caching the data - -- It is a requirement to reduce the number of requests and improve future performance -- Q - - [ ] How do we match the data points not to duplicate? - - [ ] What unique key should we use as primary key in the `balance_cache` DB? - - How about `chainID` + `address` + `token`? - - [x] Is our sqlite DB the best time series data store we have at hand? - - Yes, great integration - - [x] Do we adjust the data points later on or just add forever? - - For start we just add. We might prune old data later on based on age and granularity - - [ ] What do we already cache with transaction history API? - - [x] What granularity do we cache? - - All we fetch for now. Can't see yet how to manage gaps beside the current merging blocks implementation - - [x] What is the cache eviction policy? - - None yet, always growing. Can't see concerns of cache size yet due to scarce transactions for regular users - - Can be done later as an optimization - -View - -- Q - - [ ] How do we represent data? Each transaction is a point and interpolate in between? - - [x] Are "Overview" and "Activity" tabs affected somehow? - - It seems not, quite isolated and no plan to update transactions for now. Can be done later as an optimization - -### Dependencies - -Infura API - -### Constraints - -- Data points granularity should be optimal - - In theory it doesn't make sense to show more than 1 data point per pixel - - In practice it make no sense to show more than 1 data point per few pixels - - Q - - [ ] What is the optimal granularity? Time range based? - - [ ] How do we prune the data points to match the optimal granularity? Do we need this in practice? Can we just show all the data points? - - [ ] How about having 10 transactions in a day and showing the ALL time plot -- Q - - [ ] What is the interval for Showing All Time case? Starting from the first transaction? Or from the first block? - - It seems the current price history start from 2010 - -## Development - -Data scattered in `transfers` DB table and `balanceCache` - -Q: - -- [ ] Unify it, update? Is there a relation in between these two sources? -- [ ] How to fill the gaps? - -### Reference - -Token historical price PRs - -- status-desktop [#7599](https://github.com/status-im/status-desktop/pull/7599/files) - - See `TokenMarketValuesStore` for converting from raw data to QML friendly JS data: `ui/imports/shared/stores/TokenMarketValuesStore.qml` -- status-go [#2882](https://github.com/status-im/status-go/pull/2882/files) - -Building blocks - -- status-go - - Transaction history, common data: `CheckRecentHistory` - `services/wallet/transfer/controller.go` - - setup a running task that fetches balances for chainIDs and accounts - - caches balances in memory see `balanceCache` - - Q - - [x] Queries block ranges? - - Retrieve all "old" block ranges (from block - to block) in table `blocks_ranges` to match the specified network and address. Also order by block from - - See `BlocksRange {from, to *big.Int}` - `services/wallet/transfer/block.go` - - Merge them in continuous blocks simplifying the ranges - - See `TestGetNewRanges` - `services/wallet/transfer/block_test.go` - - Deletes the simplified and add new ranges to the table - - [x] When are block ranges fragmented? - - `setInitialBlocksRange` for all the accounts and watched addresses added initially from block 0 to latest - `services/wallet/transfer/block.go` - - Q: - - [ ] What does latest means? Latest block in the chain? - - `eth_getBlockByNumber` in `HeaderByNumber` - `services/wallet/chain/client.go` - - Event `EventFetchingRecentHistory` processes the history and updates the block ranges via `ProcessBlocks`-> `upsertRange` - `services/wallet/transfer/commands.go` - - [x] Reactor loops? - - Reactor listens to new blocks and stores transfers into the database. - - `ERC20TransfersDownloader`, `ETHDownloader` - - Updates **in memory cache of balances** maps (an address to a map of a block number and the balance of this particular address) - - `balanceCache` - `services/wallet/transfer/balance_cache.go` - - [x] Why `watchAccountsChanges`? - - To update the reactor when list of accounts is updated (added/removed) - - [ ] How do we identify a balance cache entry? -- NIM -- QML - - `RootStore` - `ui/imports/shared/stores/RootStore.qml` - - Entry point for accessing underlying NIM model - - `RightTabView` - `ui/app/AppLayouts/Wallet/views/RightTabView.qml` - - Contains the "Asset" tab from which user selects the token to see history for - - StatusQ's `StatusChartPanel` - `ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml` - - `Chart` - `ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml` - - Canvas based drawing using `Chart.js` - - `HistoryView` calls `RootStore.getTransfersByAddress` to retrieve list of transfers which are delivered as async signals - - `getTransfersByAddress` will call `status-go`'s `GetTransfersByAddress` which will update `blocks` and `transfers` DB tables - - Q: - - [ ] How is this overlapping with the check recent history? - -### TODO - -- [x] New `status-go` API to fetch balance history from existing cache using rules (granularity, time range) - - ~~In the meantime use fetch transactions to populate the cache for testing purposes~~ - - This doesn't work because of the internal transactions that don't show up in the transaction history -- [ ] Sample blocks using `eth_getBalance` -- [ ] Extend Controller to Expose balance history API -- [ ] Implement UI View to use the new API and display the graph of existing data -- [ ] Extend `status-go` - - [ ] Control fetching of new balances for history purpose - - [ ] Premature optimization? - - DB cache eviction policy - - DB cache granularity control -- [ ] Add balance cache DB and sync it with in memory cache - - [ ] Retrieve from the DB if missing exists optimization -- [ ] Extend UI View with controls for time range \ No newline at end of file diff --git a/src/app/modules/main/wallet_section/all_tokens/io_interface.nim b/src/app/modules/main/wallet_section/all_tokens/io_interface.nim index e4cd3335a..0eb818a5c 100644 --- a/src/app/modules/main/wallet_section/all_tokens/io_interface.nim +++ b/src/app/modules/main/wallet_section/all_tokens/io_interface.nim @@ -38,6 +38,12 @@ method getHistoricalDataForToken*(self: AccessInterface, symbol: string, currenc method tokenHistoricalDataResolved*(self: AccessInterface, tokenDetails: string) {.base.} = raise newException(ValueError, "No implementation available") +method fetchHistoricalBalanceForTokenAsJson*(self: AccessInterface, address: string, symbol: string, timeIntervalEnum: int) {.base.} = + raise newException(ValueError, "No implementation available") + +method tokenBalanceHistoryDataResolved*(self: AccessInterface, balanceHistoryJson: string) {.base.} = + raise newException(ValueError, "No implementation available") + # View Delegate Interface # Delegate for the view must be declared here due to use of QtObject and multi # inheritance, which is not well supported in Nim. diff --git a/src/app/modules/main/wallet_section/all_tokens/module.nim b/src/app/modules/main/wallet_section/all_tokens/module.nim index b82e1488e..fa4439719 100644 --- a/src/app/modules/main/wallet_section/all_tokens/module.nim +++ b/src/app/modules/main/wallet_section/all_tokens/module.nim @@ -102,3 +102,10 @@ method getHistoricalDataForToken*(self: Module, symbol: string, currency: string method tokenHistoricalDataResolved*(self: Module, tokenDetails: string) = self.view.tokenHistoricalDataReady(tokenDetails) + + +method fetchHistoricalBalanceForTokenAsJson*(self: Module, address: string, symbol: string, timeIntervalEnum: int) = + self.controller.fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) + +method tokenBalanceHistoryDataResolved*(self: Module, balanceHistoryJson: string) = + self.view.tokenBalanceHistoryDataReady(balanceHistoryJson) diff --git a/src/app/modules/main/wallet_section/all_tokens/view.nim b/src/app/modules/main/wallet_section/all_tokens/view.nim index 6baaf5dc1..060f72bd0 100644 --- a/src/app/modules/main/wallet_section/all_tokens/view.nim +++ b/src/app/modules/main/wallet_section/all_tokens/view.nim @@ -82,3 +82,8 @@ QtObject: self.delegate.getHistoricalDataForToken(symbol, currency) proc tokenHistoricalDataReady*(self: View, tokenDetails: string) {.signal.} + + proc fetchHistoricalBalanceForTokenAsJson*(self: View, address: string, symbol: string, timeIntervalEnum: int) {.slot.} = + self.delegate.fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) + + proc tokenBalanceHistoryDataReady*(self: View, balanceHistoryJson: string) {.signal.} \ No newline at end of file diff --git a/src/app_service/service/token/async_tasks.nim b/src/app_service/service/token/async_tasks.nim index 369895f0f..7f074e98d 100644 --- a/src/app_service/service/token/async_tasks.nim +++ b/src/app_service/service/token/async_tasks.nim @@ -85,3 +85,44 @@ const getTokenHistoricalDataTask*: Task = proc(argEncoded: string) {.gcsafe, nim } arg.finish(output) +type + BalanceHistoryTimeInterval* {.pure.} = enum + BalanceHistory7Hours = 0, + BalanceHistory1Month, + BalanceHistory6Months, + BalanceHistory1Year, + BalanceHistoryAllTime + +type + GetTokenBalanceHistoryDataTaskArg = ref object of QObjectTaskArg + chainId: int + address: string + symbol: string + timeInterval: BalanceHistoryTimeInterval + +const getTokenBalanceHistoryDataTask*: Task = proc(argEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[GetTokenBalanceHistoryDataTaskArg](argEncoded) + var response = %*{} + try: + # status-go time intervals are starting from 1 + response = backend.getBalanceHistory(arg.chainId, arg.address, int(arg.timeInterval) + 1).result + + let output = %* { + "chainId": arg.chainId, + "address": arg.address, + "symbol": arg.symbol, + "timeInterval": int(arg.timeInterval), + "historicalData": response + } + + arg.finish(output) + return + except Exception as e: + let output = %* { + "chainId": arg.chainId, + "address": arg.address, + "symbol": arg.symbol, + "timeInterval": int(arg.timeInterval), + "error": "Balance history value not found", + } + arg.finish(output) \ No newline at end of file diff --git a/src/app_service/service/token/service.nim b/src/app_service/service/token/service.nim index dff5f6fd9..913afa2d3 100644 --- a/src/app_service/service/token/service.nim +++ b/src/app_service/service/token/service.nim @@ -22,6 +22,7 @@ include async_tasks const SIGNAL_TOKEN_DETAILS_LOADED* = "tokenDetailsLoaded" const SIGNAL_TOKEN_LIST_RELOADED* = "tokenListReloaded" const SIGNAL_TOKEN_HISTORICAL_DATA_LOADED* = "tokenHistoricalDataLoaded" +const SIGNAL_BALANCE_HISTORY_DATA_READY* = "tokenBalanceHistoryDataReady" type TokenDetailsLoadedArgs* = ref object of Args @@ -43,6 +44,10 @@ type TokenHistoricalDataArgs* = ref object of Args result*: string +type + TokenBalanceHistoryDataArgs* = ref object of Args + result*: string + QtObject: type Service* = ref object of QObject events: EventEmitter @@ -198,7 +203,7 @@ QtObject: ) self.threadpool.start(arg) - proc tokenHistorticalDataResolved*(self: Service, response: string) {.slot.} = + proc tokenHistoricalDataResolved*(self: Service, response: string) {.slot.} = let responseObj = response.parseJson if (responseObj.kind != JObject): info "prepared tokens are not a json object" @@ -208,13 +213,41 @@ QtObject: result: response )) + proc tokenBalanceHistoryDataResolved*(self: Service, response: string) {.slot.} = + # TODO + let responseObj = response.parseJson + if (responseObj.kind != JObject): + info "blance history response is not a json object" + return + + self.events.emit(SIGNAL_BALANCE_HISTORY_DATA_READY, TokenBalanceHistoryDataArgs( + result: response + )) + proc getHistoricalDataForToken*(self: Service, symbol: string, currency: string, range: int) = let arg = GetTokenHistoricalDataTaskArg( tptr: cast[ByteAddress](getTokenHistoricalDataTask), vptr: cast[ByteAddress](self.vptr), - slot: "tokenHistorticalDataResolved", + slot: "tokenHistoricalDataResolved", symbol: symbol, currency: currency, range: range ) self.threadpool.start(arg) + + proc fetchHistoricalBalanceForTokenAsJson*(self: Service, address: string, symbol: string, timeInterval: BalanceHistoryTimeInterval) = + let networks = self.networkService.getNetworks() + for network in networks: + if network.enabled and network.nativeCurrencySymbol == symbol: + let arg = GetTokenBalanceHistoryDataTaskArg( + tptr: cast[ByteAddress](getTokenBalanceHistoryDataTask), + vptr: cast[ByteAddress](self.vptr), + slot: "tokenBalanceHistoryDataResolved", + chainId: network.chainId, + address: address, + symbol: symbol, + timeInterval: timeInterval + ) + self.threadpool.start(arg) + return + error "faild to find a network with the symbol", symbol \ No newline at end of file diff --git a/src/backend/backend.nim b/src/backend/backend.nim index 7cd169fbc..9cfc8f5d1 100644 --- a/src/backend/backend.nim +++ b/src/backend/backend.nim @@ -281,4 +281,9 @@ rpc(getDailyMarketValues, "wallet"): rpc(getName, "ens"): chainId: int - address: string \ No newline at end of file + address: string + +rpc(getBalanceHistory, "wallet"): + chainId: int + address: string + timeInterval: int \ No newline at end of file diff --git a/test/libs/StatusGoQt/test_wallet.cpp b/test/libs/StatusGoQt/test_wallet.cpp index f893ca59b..9b460f1b6 100644 --- a/test/libs/StatusGoQt/test_wallet.cpp +++ b/test/libs/StatusGoQt/test_wallet.cpp @@ -353,7 +353,7 @@ TEST(WalletApi, TestCheckRecentHistory) } // TODO: this is a debugging test. Augment it with local Ganache environment to have a reliable test -TEST(WalletApi, TestGetBalanceHistoryOnChain) +TEST(WalletApi, TestGetBalanceHistory) { ScopedTestAccount testAccount(test_info_->name()); @@ -366,6 +366,15 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain) const auto updatedAccounts = Accounts::getAccounts(); ASSERT_EQ(updatedAccounts.size(), 3); + auto networks = Wallet::getEthereumChains(false); + ASSERT_GT(networks.size(), 0); + auto mainNetIt = + std::find_if(networks.begin(), networks.end(), [](const auto& n) { return n.chainName == "Mainnet"; }); + ASSERT_NE(mainNetIt, networks.end()); + const auto& mainNet = *mainNetIt; + + auto tokens = Wallet::getTokens(mainNet.chainId); + const auto newAccountIt = std::find_if(updatedAccounts.begin(), updatedAccounts.end(), [newTestAccountName](const auto& a) { return a.name == newTestAccountName; @@ -373,18 +382,24 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain) ASSERT_NE(newAccountIt, updatedAccounts.end()); const auto& newAccount = *newAccountIt; - auto testIntervals = {std::chrono::round(1h), - std::chrono::round(std::chrono::days(1)), - std::chrono::round(std::chrono::days(7)), - std::chrono::round(std::chrono::months(1)), - std::chrono::round(std::chrono::months(6)), - std::chrono::round(std::chrono::years(1)), - std::chrono::round(std::chrono::years(100))}; - auto sampleCount = 10; - for(const auto& historyDuration : testIntervals) + auto testIntervals = {Wallet::BalanceHistoryTimeInterval::BalanceHistory7Hours, + Wallet::BalanceHistoryTimeInterval::BalanceHistory1Month, + Wallet::BalanceHistoryTimeInterval::BalanceHistory6Months, + Wallet::BalanceHistoryTimeInterval::BalanceHistory1Year, + Wallet::BalanceHistoryTimeInterval::BalanceHistoryAllTime}; + + std::map testIntervalsStrs{ + {Wallet::BalanceHistoryTimeInterval::BalanceHistory7Hours, "7H"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistory1Month, "1M"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistory6Months, "6M"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistory1Year, "1Y"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistoryAllTime, "All"}}; + + for(const auto& historyInterval : testIntervals) { - auto balanceHistory = Wallet::getBalanceHistoryOnChain(newAccount.address, historyDuration, sampleCount); - ASSERT_TRUE(balanceHistory.size() > 0); // TODO: we get one extra, match sample size + // TODO: next `mainNet.nativeCurrencySymbol`, later `tokens.symbol` + auto balanceHistory = Wallet::getBalanceHistory(mainNet.chainId, newAccount.address, historyInterval); + ASSERT_TRUE(balanceHistory.size() > 0); auto weiToEth = [](const StatusGo::Wallet::BigInt& wei) -> double { StatusGo::Wallet::BigInt q; // wei / eth @@ -398,19 +413,18 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain) return q.convert_to() + (qSzabos.convert_to() / ((weiD / szaboD).convert_to())); }; - QFile file(QString("/tmp/balance_history-%1s.csv").arg(historyDuration.count())); + QFile file(QString("/tmp/balance_history-%s.csv").arg(testIntervalsStrs[historyInterval])); if(file.open(QIODevice::WriteOnly | QIODevice::Text)) { QTextStream out(&file); out << "Balance, Timestamp" << Qt::endl; - for(int i = balanceHistory.size() - 1; i >= 0; --i) + for(int i = 0; i < balanceHistory.size(); ++i) { out << weiToEth(balanceHistory[i].value) << "," << balanceHistory[i].time.toSecsSinceEpoch() << Qt::endl; } } file.close(); - sampleCount += 10; } } diff --git a/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml b/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml index 9f019ba57..605ca5608 100644 --- a/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml +++ b/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml @@ -18,7 +18,7 @@ Item { property real minStep: 12000 property real maxStep: 22000 - property var graphTabsModel: [{text: "Price", enabled: true}, {text: "Balance", enabled: false}] + property var graphTabsModel: [{text: "Price", enabled: true}, {text: "Balance", enabled: true}] property var timeRangeTabsModel: [{text: "1H", enabled: true}, {text: "1D", enabled: true},{text: "7D", enabled: true}, {text: "1M", enabled: true}, {text: "6M", enabled: true}, diff --git a/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml b/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml index 41f2c2c55..110253ca8 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml @@ -58,6 +58,11 @@ Page { /*! \qmlproperty var StatusChartPanel::graphsModel This property holds the graphs model options to be set on the left side tab bar of the header. + The JS array entries are expected to be objects with the following properties: + - text (string): The text to be displayed on the tab + - enabled (bool): Whether the tab is enabled or not + - isTimeRange (bool): Whether the tab is a time range tab or graph type tab + - privateIdentifier (string): An optional unique identifier for the tab that will be received \c headerTabClicked signal. Otherwise, the text will be used. */ property var graphsModel /*! @@ -81,8 +86,10 @@ Page { /*! \qmlproperty string StatusChartPanel::selectedTimeRange This property holds holds the text of the current time range tab bar selected tab. + + \todo no need for undefined state, make tab models declaratively */ - property string selectedTimeRange: timeRangeTabBar.currentItem.text + property string selectedTimeRange: timeRangeTabBar.currentItem ? timeRangeTabBar.currentItem.text : "" /*! \qmlproperty string StatusChartPanel::defaultTimeRangeIndexShown @@ -94,15 +101,18 @@ Page { \qmlsignal This signal is emitted when a header tab bar is clicked. */ - signal headerTabClicked(string text) + signal headerTabClicked(var privateIdentifier, bool isTimeRange) Component { id: tabButton StatusTabButton { + property var privateIdentifier: null + property bool isTimeRange: false + leftPadding: 0 width: implicitWidth onClicked: { - root.headerTabClicked(text); + root.headerTabClicked(privateIdentifier, isTimeRange); } } } @@ -111,7 +121,9 @@ Page { if (!!timeRangeModel) { for (var i = 0; i < timeRangeModel.length; i++) { var timeTab = tabButton.createObject(root, { text: timeRangeModel[i].text, - enabled: timeRangeModel[i].enabled }); + enabled: timeRangeModel[i].enabled, + isTimeRange: true, + privateIdentifier: timeRangeModel[i].text }); timeRangeTabBar.addItem(timeTab); } timeRangeTabBar.currentIndex = defaultTimeRangeIndexShown @@ -119,7 +131,9 @@ Page { if (!!graphsModel) { for (var j = 0; j < graphsModel.length; j++) { var graphTab = tabButton.createObject(root, { text: graphsModel[j].text, - enabled: graphsModel[j].enabled }); + enabled: graphsModel[j].enabled, + isTimeRange: false, + privateIdentifier: typeof graphsModel[j].id !== "undefined" ? graphsModel[j].id : null}); graphsTabBar.addItem(graphTab); } } diff --git a/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml b/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml index 23fc7ec6f..29bc1440d 100644 --- a/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml +++ b/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml @@ -24,6 +24,13 @@ Canvas { signal animationFinished() + + function updateToNewData() + { + jsChart.update('none'); + root.requestPaint(); + } + function animateToNewData() { chartAnimationProgress = 0.1; diff --git a/ui/app/AppLayouts/Wallet/WalletLayout.qml b/ui/app/AppLayouts/Wallet/WalletLayout.qml index a57e3a14d..5be872c65 100644 --- a/ui/app/AppLayouts/Wallet/WalletLayout.qml +++ b/ui/app/AppLayouts/Wallet/WalletLayout.qml @@ -59,7 +59,6 @@ Item { } } - StatusSectionLayout { anchors.top: seedPhraseWarning.bottom height: root.height - seedPhraseWarning.height diff --git a/ui/app/AppLayouts/Wallet/views/RightTabView.qml b/ui/app/AppLayouts/Wallet/views/RightTabView.qml index c90853f16..975f24dfc 100644 --- a/ui/app/AppLayouts/Wallet/views/RightTabView.qml +++ b/ui/app/AppLayouts/Wallet/views/RightTabView.qml @@ -117,9 +117,12 @@ Item { } AssetsDetailView { id: assetDetailView + Layout.fillWidth: true Layout.fillHeight: true visible: (stack.currentIndex === 2) + + address: RootStore.currentAccount.mixedcaseAddress } TransactionDetailView { id: transactionDetailView diff --git a/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml b/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml index d0b6c88fa..c15e7d52c 100644 --- a/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml +++ b/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml @@ -96,12 +96,12 @@ StatusListItem { tagsModel.clear() if (root.keyPairAccounts === "") { // should never be here, as it's not possible to have keypair item without at least a single account - console.warning("accounts list is empty for selecting keycard pair") + console.warn("accounts list is empty for selecting keycard pair") return } let obj = JSON.parse(root.keyPairAccounts) if (obj.error) { - console.warning("error parsing accounts for selecting keycard pair, error: ", obj.error) + console.warn("error parsing accounts for selecting keycard pair, error: ", obj.error) return } diff --git a/ui/imports/shared/stores/ChartStoreBase.qml b/ui/imports/shared/stores/ChartStoreBase.qml new file mode 100644 index 000000000..d9a6ee305 --- /dev/null +++ b/ui/imports/shared/stores/ChartStoreBase.qml @@ -0,0 +1,126 @@ +import QtQuick 2.13 + +import utils 1.0 + +Item { + id: root + + // @see src/app_service/service/token/async_tasks.nim BalanceHistoryTimeInterval + enum TimeRange { + Weekly = 0, + Monthly, + HalfYearly, + Yearly, + All + } + + readonly property var timeRangeTabsModel: [ + {text: qsTr("7D"), enabled: true, timeRange: ChartStoreBase.TimeRange.Weekly}, + {text: qsTr("1M"), enabled: true, timeRange: ChartStoreBase.TimeRange.Monthly}, + {text: qsTr("6M"), enabled: true, timeRange: ChartStoreBase.TimeRange.HalfYearly}, + {text: qsTr("1Y"), enabled: true, timeRange: ChartStoreBase.TimeRange.Yearly}, + {text: qsTr("ALL"), enabled: true, timeRange: ChartStoreBase.TimeRange.All}] + + property var weeklyData: [] + property var monthlyData: [] + property var halfYearlyData: [] + property var yearlyData: [] + property var allData: [] + + property var weeklyTimeRange: [] + property var monthlyTimeRange: [] + property var halfYearlyTimeRange: [] + property var yearlyTimeRange: [] + property var allTimeRange: [] + + property int monthlyMaxTicks: monthlyTimeRange.length/d.hoursInADay + property int weeklyMaxTicks: weeklyTimeRange.length/d.hoursInADay + property int halfYearlyMaxTicks: halfYearlyTimeRange.length/d.avgLengthOfMonth + property int yearlyMaxTicks: yearlyTimeRange.length/d.avgLengthOfMonth + property int allTimeRangeTicks: 0 + + // BEWARE that timeRange, dataRange and maxTicks properties are coupled with the timeRangeTabsModel order through + // indexing. See StatusChartPanel.timeRangeTabBarIndex + readonly property var timeRange: [ + {'7D': weeklyTimeRange}, + {'1M': monthlyTimeRange}, + {'6M': halfYearlyTimeRange}, + {'1Y': yearlyTimeRange}, + {'ALL': allTimeRange} + ] + + readonly property var dataRange: [ + {'7D': weeklyData}, + {'1M': monthlyData}, + {'6M': halfYearlyData}, + {'1Y': yearlyData}, + {'ALL': allData} + ] + + readonly property var maxTicks: [ + {'7D': weeklyMaxTicks}, + {'1M': monthlyMaxTicks}, + {'6M': halfYearlyMaxTicks}, + {'1Y': yearlyMaxTicks}, + {'ALL': allTimeRangeTicks} + ] + + /// \timeRange is the time range of the data that was updated + signal newDataReady(int timeRange) + + function timeRangeEnumToStr(enumVal) { + return d.timeRangeTabsModel.get(enumVal) + } + function timeRangeStrToEnum(str) { + return d.timeRangeStrToEnumMap.get(str) + } + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function updateRequestTime(timeRange) { + d.requestTimes.set(timeRange, new Date()) + } + + function resetRequestTime() { + d.requestTimes.set(timeRange, new Date(0)) + } + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function isTimeToRequest(timeRange) { + if(d.requestTimes.has(timeRange)) { + const hoursToIgnore = 12 + let existing = d.requestTimes.get(timeRange) + let willBeMs = new Date(existing.getTime() + (hoursToIgnore * 3600000)) + return new Date(willBeMs) < new Date() + } + else + return true + } + + QtObject { + id: d + + readonly property int hoursInADay: 24 + readonly property int avgLengthOfMonth: 30 + property var timeRangeEnumToStrMap: null + property var timeRangeStrToEnumMap: null + property var requestTimes: null + } + + Component.onCompleted: { + if(d.timeRangeEnumToStrMap === null) { + d.timeRangeEnumToStrMap = new Map() + for (const x of timeRangeTabsModel) { + d.timeRangeEnumToStrMap.set(x.timeRange, x.text) + } + d.timeRangeStrToEnumMap = new Map() + for (const x of d.timeRangeEnumToStrMap.entries()) { + let key = x[0] + let val = x[1] + d.timeRangeStrToEnumMap.set(val, key) + } + } + if(d.requestTimes === null) { + d.requestTimes = new Map() + } + } +} \ No newline at end of file diff --git a/ui/imports/shared/stores/RootStore.qml b/ui/imports/shared/stores/RootStore.qml index 3a0db36c7..f460ae4c0 100644 --- a/ui/imports/shared/stores/RootStore.qml +++ b/ui/imports/shared/stores/RootStore.qml @@ -204,4 +204,9 @@ QtObject { function getHistoricalDataForToken(symbol, currency) { walletSectionAllTokens.getHistoricalDataForToken(symbol,currency) } + + // TODO: range until we optimize to cache the data and abuse the requests + function fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) { + walletSectionAllTokens.fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) + } } diff --git a/ui/imports/shared/stores/TokenBalanceHistoryStore.qml b/ui/imports/shared/stores/TokenBalanceHistoryStore.qml new file mode 100644 index 000000000..2151a8b13 --- /dev/null +++ b/ui/imports/shared/stores/TokenBalanceHistoryStore.qml @@ -0,0 +1,89 @@ +import QtQuick 2.13 + +import utils 1.0 + +ChartStoreBase { + id: root + + /*required*/ property string address: "" + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function setData(timeRange, timeRangeData, balanceData) { + switch(timeRange) { + case ChartStoreBase.TimeRange.Weekly: + root.weeklyData = balanceData + root.weeklyTimeRange = timeRangeData + root.weeklyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.Monthly: + root.monthlyData = balanceData + root.monthlyTimeRange = timeRangeData + root.monthlyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.HalfYearly: + root.halfYearlyData = balanceData + root.halfYearlyTimeRange = timeRangeData + root.halfYearlyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.Yearly: + root.yearlyData = balanceData + root.yearlyTimeRange = timeRangeData + root.yearlyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.All: + root.allData = balanceData + root.allTimeRange = timeRangeData + root.allTimeRangeTicks = 0 + break; + default: + console.warn("Invalid or unsupported time range") + break; + } + root.newDataReady(timeRange) + } + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function resetData(timeRange) { + root.setData(timeRange, [], []) + } + + Connections { + target: walletSectionAllTokens + onTokenBalanceHistoryDataReady: (balanceHistory) => { + // chainId, address, symbol, timeInterval + let response = JSON.parse(balanceHistory) + if (response === null) { + console.warn("error parsing balance history json message data") + root.resetRequestTime() + return + } + + if(typeof response.historicalData === "undefined" || response.historicalData === null || response.historicalData.length == 0) { + console.warn("error no data in balance history. Must be an error from status-go") + root.resetRequestTime() + return + } else if(response.address !== root.address) { + // Ignore data for other addresses. Will be handled by other instances of this store + return + } + + root.resetData(response.timeInterval) + + var tmpTimeRange = [] + var tmpDataValues = [] + for(let i = 0; i < response.historicalData.length; i++) { + let dataEntry = response.historicalData[i] + + let dateString = response.timeInterval == ChartStoreBase.TimeRange.Weekly || response.timeInterval == ChartStoreBase.TimeRange.Monthly + ? Utils.getDayMonth(dataEntry.time * 1000, RootStore.accountSensitiveSettings.is24hTimeFormat) + : Utils.getMonthYear(dataEntry.time * 1000) + tmpTimeRange.push(dateString) + + tmpDataValues.push(parseFloat(globalUtils.wei2Eth(dataEntry.value, 18))) + } + + root.setData(response.timeInterval, tmpTimeRange, tmpDataValues) + root.updateRequestTime(response.timeInterval) + } + } +} \ No newline at end of file diff --git a/ui/imports/shared/stores/TokenMarketValuesStore.qml b/ui/imports/shared/stores/TokenMarketValuesStore.qml index 253f1a77c..e6f0ec49d 100644 --- a/ui/imports/shared/stores/TokenMarketValuesStore.qml +++ b/ui/imports/shared/stores/TokenMarketValuesStore.qml @@ -2,97 +2,42 @@ import QtQuick 2.13 import utils 1.0 -QtObject { +ChartStoreBase { id: root - - enum TimeRange { - Weekly = 0, - Monthly, - HalfYearly, - Yearly, - All - } - - readonly property int hoursInADay: 24 - readonly property int avgLengthOfMonth: 30 - - readonly property var graphTabsModel: [{text: qsTr("Price"), enabled: true}, {text: qsTr("Balance"), enabled: false}] - readonly property var timeRangeTabsModel: [{text: qsTr("7D"), enabled: true}, - {text: qsTr("1M"), enabled: true}, {text: qsTr("6M"), enabled: true}, - {text: qsTr("1Y"), enabled: true}, {text: qsTr("ALL"), enabled: true}] - - property var weeklyData - property var monthlyData - property var halfYearlyData - property var yearlyData - property var allData - - property var weeklyTimeRange - property var monthlyTimeRange - property var halfYearlyTimeRange - property var yearlyTimeRange - property var allTimeRange - - readonly property var timeRange: [ - {'7D': weeklyTimeRange}, - {'1M': monthlyTimeRange}, - {'6M': halfYearlyTimeRange}, - {'1Y': yearlyTimeRange}, - {'ALL': allTimeRange} - ] - - readonly property var dataRange: [ - {'7D': weeklyData}, - {'1M': monthlyData}, - {'6M': halfYearlyData}, - {'1Y': yearlyData}, - {'ALL': allData} - ] - - property int allTimeRangeTicks: 0 - - readonly property var maxTicks: [ - {'7D': weeklyTimeRange.length/hoursInADay}, - {'1M': monthlyTimeRange.length/hoursInADay}, - {'6M': halfYearlyTimeRange.length/avgLengthOfMonth}, - {'1Y': yearlyTimeRange.length/avgLengthOfMonth}, - {'ALL': allTimeRangeTicks} - ] - function setTimeAndValueData(data, range) { var marketValues = [] var timeRanges = [] for (var i = 0; i < data.length; ++i) { marketValues[i] = data[i].close; - timeRanges[i] = range === TokenMarketValuesStore.TimeRange.Weekly || range === TokenMarketValuesStore.TimeRange.Monthly ? + timeRanges[i] = range === ChartStoreBase.TimeRange.Weekly || range === ChartStoreBase.TimeRange.Monthly ? Utils.getDayMonth(data[i].time * 1000, RootStore.accountSensitiveSettings.is24hTimeFormat): Utils.getMonthYear(data[i].time * 1000) } switch(range) { - case TokenMarketValuesStore.TimeRange.Weekly: { + case ChartStoreBase.TimeRange.Weekly: { weeklyData = marketValues weeklyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.Monthly: { + case ChartStoreBase.TimeRange.Monthly: { monthlyData = marketValues monthlyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.HalfYearly: { + case ChartStoreBase.TimeRange.HalfYearly: { halfYearlyData = marketValues halfYearlyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.Yearly: { + case ChartStoreBase.TimeRange.Yearly: { yearlyData = marketValues yearlyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.All: { + case ChartStoreBase.TimeRange.All: { allData = marketValues allTimeRange = timeRanges if(data.length > 0) diff --git a/ui/imports/shared/views/AssetsDetailView.qml b/ui/imports/shared/views/AssetsDetailView.qml index d8074c1c6..b5a1e0e3a 100644 --- a/ui/imports/shared/views/AssetsDetailView.qml +++ b/ui/imports/shared/views/AssetsDetailView.qml @@ -14,14 +14,24 @@ import shared.controls 1.0 import "../stores" +/// \beware: heavy shortcuts here, refactor to match the requirements when touching this again +/// \todo split into token history and balance views; they have different requirements that introduce unnecessary complexity +/// \todo take a declarative approach, move logic into the typed backend and remove multiple source of truth (e.g. time ranges) Item { id: root property var token + /*required*/ property string address: "" + + function createStore(address) { + return balanceHistoryComponent.createObject(null, {address: address}) + } QtObject { id: d property var marketValueStore : RootStore.marketValueStore + // TODO: Should be temporary until non native tokens are supported by balance history + property bool isNativeToken: typeof token !== "undefined" && token ? token.symbol === "ETH" : false } Connections { @@ -29,7 +39,7 @@ Item { onTokenHistoricalDataReady: { let response = JSON.parse(tokenDetails) if (response === null) { - console.debug("error parsing message for tokenHistoricalDataReady: error: ", response.error) + console.debug("error parsing json message for tokenHistoricalDataReady") return } if(response.historicalData === null || response.historicalData <= 0) @@ -59,6 +69,11 @@ Item { } } + enum GraphType { + Price = 0, + Balance + } + Loader { id: graphDetailLoader width: parent.width @@ -68,14 +83,66 @@ Item { active: root.visible sourceComponent: StatusChartPanel { id: graphDetail - graphsModel: d.marketValueStore.graphTabsModel - defaultTimeRangeIndexShown: TokenMarketValuesStore.TimeRange.All - timeRangeModel: d.marketValueStore.timeRangeTabsModel - onHeaderTabClicked: chart.animateToNewData() + + property int selectedGraphType: AssetsDetailView.GraphType.Price + property var selectedStore: d.marketValueStore + + function dataReady() { + return typeof selectedStore != "undefined" + } + function timeRangeSelected() { + return dataReady() && graphDetail.timeRangeTabBarIndex >= 0 && graphDetail.selectedTimeRange.length > 0 + } + + readonly property var labelsData: { + return timeRangeSelected() + ? selectedStore.timeRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] + : [] + } + readonly property var dataRange: { + return timeRangeSelected() + ? selectedStore.dataRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] + : [] + } + readonly property var maxTicksLimit: { + return timeRangeSelected() && typeof selectedStore.maxTicks != "undefined" + ? selectedStore.maxTicks[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] + : 0 + } + + graphsModel: [ + {text: qsTr("Price"), enabled: true, id: AssetsDetailView.GraphType.Price}, + { + text: qsTr("Balance"), + enabled: false, // TODO: Enable after adding ECR20 token support and DB cache. Current prototype implementation works only for d.isNativeToken + id: AssetsDetailView.GraphType.Balance + }, + ] + defaultTimeRangeIndexShown: ChartStoreBase.TimeRange.All + timeRangeModel: dataReady() && selectedStore.timeRangeTabsModel + onHeaderTabClicked: (privateIdentifier, isTimeRange) => { + if(!isTimeRange && graphDetail.selectedGraphType !== privateIdentifier) { + graphDetail.selectedGraphType = privateIdentifier + } + + if(graphDetail.selectedGraphType === AssetsDetailView.GraphType.Balance) { + let selectedTimeRangeEnum = balanceStore.timeRangeStrToEnum(graphDetail.selectedTimeRange) + if(balanceStore.isTimeToRequest(selectedTimeRangeEnum)) { + RootStore.fetchHistoricalBalanceForTokenAsJson(root.address, token.symbol, selectedTimeRangeEnum) + balanceStore.updateRequestTime(selectedTimeRangeEnum) + } + } + + if(!isTimeRange) { + graphDetail.selectedStore = graphDetail.selectedGraphType === AssetsDetailView.GraphType.Price ? d.marketValueStore : balanceStore + } + + chart.animateToNewData() + } chart.chartType: 'line' chart.chartData: { return { - labels: d.marketValueStore.timeRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange], + labels: graphDetail.labelsData, datasets: [{ xAxisId: 'x-axis-1', yAxisId: 'y-axis-1', @@ -83,7 +150,7 @@ Item { borderColor: (Theme.palette.name === "dark") ? 'rgba(136, 176, 255, 1)' : 'rgba(67, 96, 223, 1)', borderWidth: 3, pointRadius: 0, - data: d.marketValueStore.dataRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange], + data: graphDetail.dataRange, parsing: false, }] } @@ -133,7 +200,7 @@ Item { padding: 16, maxRotation: 0, minRotation: 0, - maxTicksLimit: d.marketValueStore.maxTicks[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange], + maxTicksLimit: graphDetail.maxTicksLimit, }, }], yAxes: [{ @@ -149,6 +216,10 @@ Item { axis.paddingTop = 25; axis.paddingBottom = 0; }, + afterDataLimits: (axis) => { + if(axis.min < 0) + axis.min = 0; + }, ticks: { fontSize: 10, fontColor: (Theme.palette.name === "dark") ? '#909090' : '#939BA1', @@ -161,6 +232,19 @@ Item { } } } + + TokenBalanceHistoryStore { + id: balanceStore + + address: root.address + + onNewDataReady: (timeRange) => { + let selectedTimeRange = timeRangeStrToEnum(graphDetail.selectedTimeRange) + if (timeRange === selectedTimeRange && address === root.address) { + chart.updateToNewData() + } + } + } } } @@ -277,7 +361,7 @@ Item { tagPrimaryLabel.text: qsTr("Website") controlBackground.color: Theme.palette.baseColor2 controlBackground.border.color: "transparent" - visible: token && token.assetWebsiteUrl !== "" + visible: typeof token != "undefined" && token && token.assetWebsiteUrl !== "" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor @@ -293,7 +377,7 @@ Item { tagSecondaryLabel.text: token && token.smartContractAddress !== "" ? token.smartContractAddress : "---" controlBackground.color: Theme.palette.baseColor2 controlBackground.border.color: "transparent" - visible: token && token.builtOn !== "" && token.smartContractAddress !== "" + visible: typeof token != "undefined" && token && token.builtOn !== "" && token.smartContractAddress !== "" } } }