From 1b639213a8bc90ea81be06c3a9de571fad9f0297 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 12 Sep 2025 15:19:29 +0000 Subject: [PATCH 1/5] Refactor: Populate coins from SDK and improve price fetching Co-authored-by: charl --- .../price_chart/price_chart_bloc.dart | 20 +++++++++++++++++++ lib/bloc/coins_bloc/coins_repo.dart | 17 ++++++++++++---- .../widgets/coin_select_item_widget.dart | 5 +++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart b/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart index 24191a8f64..87ad27b8cd 100644 --- a/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart +++ b/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart @@ -4,6 +4,7 @@ import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:rational/rational.dart'; +import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -26,7 +27,26 @@ class PriceChartBloc extends Bloc { ) async { emit(state.copyWith(status: PriceChartStatus.loading)); try { + // Populate available coins for the selector from SDK assets if empty Map fetchedCexCoins = state.availableCoins; + if (fetchedCexCoins.isEmpty) { + final Map allAssets = _sdk.assets.available; + final entries = allAssets.values + .where((asset) => !excludedAssetList.contains(asset.id.id)) + .where((asset) => !asset.protocol.isTestnet) + .map( + (asset) => MapEntry( + asset.id, + CoinPriceInfo( + ticker: asset.id.symbol.assetConfigId, + selectedPeriodIncreasePercentage: 0.0, + id: asset.id.id, + name: asset.id.name, + ), + ), + ); + fetchedCexCoins = Map.fromEntries(entries); + } final List> futures = event.symbols .map((symbol) => _sdk.getSdkAsset(symbol).id) diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 32382b5dc4..3a94bfcec7 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -538,14 +538,22 @@ class CoinsRepo { } Future?> fetchCurrentPrices() async { - // Try to use the SDK's price manager to get prices for active coins + // Fetch prices for a broad set of assets so unauthenticated users + // also see prices and 24h changes in lists and charts. + // Prefer activated assets if available (to limit requests when logged in), + // otherwise fall back to all available SDK assets. final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); + final Iterable targetAssets = activatedAssets.isNotEmpty + ? activatedAssets + : _kdfSdk.assets.available.values; + // Filter out excluded and testnet assets, as they are not expected // to have valid prices available at any of the providers - final validActivatedAssets = activatedAssets + final validAssets = targetAssets .where((asset) => !excludedAssetList.contains(asset.id.id)) .where((asset) => !asset.protocol.isTestnet); - for (final asset in validActivatedAssets) { + + for (final asset in validAssets) { try { // Use maybeFiatPrice to avoid errors for assets not tracked by CEX final fiatPrice = await _kdfSdk.marketData.maybeFiatPrice(asset.id); @@ -560,7 +568,8 @@ class CoinsRepo { // Continue without 24h change data } - _pricesCache[asset.id.symbol.configSymbol] = CexPrice( + final symbolKey = asset.id.symbol.configSymbol.toUpperCase(); + _pricesCache[symbolKey] = CexPrice( assetId: asset.id, price: fiatPrice, lastUpdated: DateTime.now(), diff --git a/lib/shared/widgets/coin_select_item_widget.dart b/lib/shared/widgets/coin_select_item_widget.dart index 47deaf7bff..54ac60cdf0 100644 --- a/lib/shared/widgets/coin_select_item_widget.dart +++ b/lib/shared/widgets/coin_select_item_widget.dart @@ -91,8 +91,9 @@ class CoinSelectItemWidget extends StatelessWidget { ), Expanded( child: DefaultTextStyle( - style: theme.inputDecorationTheme.labelStyle ?? - theme.textTheme.bodyMedium!, + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onSurface, + ), child: title ?? Text(name), ), ), From 33407e507300f998d8854dc825144265d9752091 Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 13 Sep 2025 13:31:16 +0200 Subject: [PATCH 2/5] refactor(coins-repo): use wallet coins instead of activated coins --- lib/bloc/coins_bloc/coins_repo.dart | 5 +- lib/model/kdf_auth_metadata_extension.dart | 81 ++++++++++++++++++++-- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 3a94bfcec7..3d2ceb31f3 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -1,9 +1,7 @@ import 'dart:async'; -import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -27,7 +25,6 @@ import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; -import 'package:web_dex/shared/constants.dart'; class CoinsRepo { CoinsRepo({required KomodoDefiSdk kdfSdk, required MM2 mm2}) @@ -542,7 +539,7 @@ class CoinsRepo { // also see prices and 24h changes in lists and charts. // Prefer activated assets if available (to limit requests when logged in), // otherwise fall back to all available SDK assets. - final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); + final activatedAssets = await _kdfSdk.getWalletAssets(); final Iterable targetAssets = activatedAssets.isNotEmpty ? activatedAssets : _kdfSdk.assets.available.values; diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 92bcac6cd4..077e028424 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -1,25 +1,45 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; extension KdfAuthMetadataExtension on KomodoDefiSdk { + /// Checks if a wallet with the specified ID exists in the system. + /// + /// Returns `true` if a user with the given [walletId] is found among + /// all registered users, `false` otherwise. Future walletExists(String walletId) async { final users = await auth.getUsers(); return users.any((user) => user.walletId.name == walletId); } + /// Returns the wallet associated with the currently authenticated user. + /// + /// Returns `null` if no user is currently signed in. Future currentWallet() async { final user = await auth.currentUser; return user?.wallet; } + /// Returns the stored list of wallet coin/asset IDs. + /// + /// If no user is signed in, returns an empty list. Future> getWalletCoinIds() async { final user = await auth.currentUser; return user?.metadata.valueOrNull>('activated_coins') ?? []; } + /// Returns the stored list of wallet coins converted from asset configuration IDs. + /// + /// This method retrieves the coin IDs from user metadata and converts them + /// to [Coin] objects. Uses `single` to maintain existing behavior which will + /// throw an exception if multiple assets share the same ticker. + /// + /// If no user is signed in, returns an empty list. + /// + /// Throws [StateError] if multiple assets are found with the same configuration ID. Future> getWalletCoins() async { final coinIds = await getWalletCoinIds(); return coinIds @@ -30,30 +50,79 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { .toList(); } + /// Returns the stored list of wallet assets from asset configuration IDs. + /// + /// This method retrieves the coin IDs from user metadata and converts them + /// to [Asset] objects. Uses `single` to ensure only one asset per configuration ID. + /// + /// If no user is signed in, returns an empty list. + /// + /// Throws [StateError] if multiple assets are found with the same configuration ID. + Future> getWalletAssets() async { + final coinIds = await getWalletCoinIds(); + return coinIds + .map((coinId) => assets.findAssetsByConfigId(coinId).single) + .toList(); + } + + /// Adds new coin/asset IDs to the current user's activated coins list. + /// + /// This method merges the provided [coins] with the existing activated coins, + /// ensuring no duplicates. The merged list is then stored in user metadata. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [coins] - An iterable of coin/asset configuration IDs to add. Future addActivatedCoins(Iterable coins) async { - final existingCoins = (await auth.currentUser) - ?.metadata - .valueOrNull>('activated_coins') ?? + final existingCoins = + (await auth.currentUser)?.metadata.valueOrNull>( + 'activated_coins', + ) ?? []; final mergedCoins = {...existingCoins, ...coins}.toList(); await auth.setOrRemoveActiveUserKeyValue('activated_coins', mergedCoins); } + /// Removes specified coin/asset IDs from the current user's activated coins list. + /// + /// This method removes all occurrences of the provided [coins] from the user's + /// activated coins list and updates the stored metadata. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [coins] - A list of coin/asset configuration IDs to remove. Future removeActivatedCoins(List coins) async { - final existingCoins = (await auth.currentUser) - ?.metadata - .valueOrNull>('activated_coins') ?? + final existingCoins = + (await auth.currentUser)?.metadata.valueOrNull>( + 'activated_coins', + ) ?? []; existingCoins.removeWhere((coin) => coins.contains(coin)); await auth.setOrRemoveActiveUserKeyValue('activated_coins', existingCoins); } + /// Sets the seed backup confirmation status for the current user. + /// + /// This method stores whether the user has confirmed backing up their seed phrase. + /// This is typically used to track wallet security compliance. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [hasBackup] - Whether the seed has been backed up. Defaults to `true`. Future confirmSeedBackup({bool hasBackup = true}) async { await auth.setOrRemoveActiveUserKeyValue('has_backup', hasBackup); } + /// Sets the wallet type for the current user. + /// + /// This method stores the wallet type in user metadata, which can be used + /// to determine wallet-specific behavior and features. + /// + /// If no user is currently signed in, the operation will complete but have no effect. + /// + /// [type] - The wallet type to set for the current user. Future setWalletType(WalletType type) async { await auth.setOrRemoveActiveUserKeyValue('type', type.name); } From 2c6e4153c9ac00fd366c3aa49bc0ff0c4040d6e2 Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 13 Sep 2025 13:31:55 +0200 Subject: [PATCH 3/5] refactor(portfolio-growth): migrate remaining change24h methods to SDK --- .../portfolio_growth_bloc.dart | 46 ++++++++----------- .../portfolio_growth_repository.dart | 37 --------------- 2 files changed, 20 insertions(+), 63 deletions(-) diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index b66d1bc512..e2ee491eb4 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -21,9 +21,11 @@ part 'portfolio_growth_state.dart'; class PortfolioGrowthBloc extends Bloc { PortfolioGrowthBloc({ - required this.portfolioGrowthRepository, - required this.sdk, - }) : super(const PortfolioGrowthInitial()) { + required PortfolioGrowthRepository portfolioGrowthRepository, + required KomodoDefiSdk sdk, + }) : _sdk = sdk, + _portfolioGrowthRepository = portfolioGrowthRepository, + super(const PortfolioGrowthInitial()) { // Use the restartable transformer for period change events to avoid // overlapping events if the user rapidly changes the period (i.e. faster // than the previous event can complete). @@ -38,8 +40,8 @@ class PortfolioGrowthBloc on(_onClearPortfolioGrowth); } - final PortfolioGrowthRepository portfolioGrowthRepository; - final KomodoDefiSdk sdk; + final PortfolioGrowthRepository _portfolioGrowthRepository; + final KomodoDefiSdk _sdk; final _log = Logger('PortfolioGrowthBloc'); void _onClearPortfolioGrowth( @@ -120,7 +122,7 @@ class PortfolioGrowthBloc // In case most coins are activating on wallet startup, wait for at least // 50% of the coins to be enabled before attempting to load the uncached // chart. - await sdk.waitForEnabledCoinsToPassThreshold(event.coins); + await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. @@ -143,12 +145,9 @@ class PortfolioGrowthBloc // recover at the cost of a longer first loading time. } - final periodicUpdate = Stream.periodic(event.updateFrequency) - .asyncMap((_) async { - // Update prices before fetching chart data - await portfolioGrowthRepository.updatePrices(); - return _fetchPortfolioGrowthChart(event); - }); + final periodicUpdate = Stream.periodic( + event.updateFrequency, + ).asyncMap((_) async => _fetchPortfolioGrowthChart(event)); // Use await for here to allow for the async update handler. The previous // implementation awaited the emit.forEach to ensure that cancelling the @@ -179,7 +178,7 @@ class PortfolioGrowthBloc ) async { final List coins = List.from(event.coins); for (final coin in event.coins) { - final isCoinSupported = await portfolioGrowthRepository + final isCoinSupported = await _portfolioGrowthRepository .isCoinChartSupported(coin.id, event.fiatCoinId); if (!isCoinSupported) { coins.remove(coin); @@ -193,7 +192,7 @@ class PortfolioGrowthBloc PortfolioGrowthLoadRequested event, { required bool useCache, }) async { - final chart = await portfolioGrowthRepository.getPortfolioGrowthChart( + final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( coins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, @@ -204,10 +203,6 @@ class PortfolioGrowthBloc return state; } - // Fetch prices before calculating total change - // This ensures we have the latest prices in the cache - await portfolioGrowthRepository.updatePrices(); - final totalBalance = _calculateTotalBalance(coins); final totalChange24h = await _calculateTotalChange24h(coins); final percentageChange24h = await _calculatePercentageChange24h(coins); @@ -230,7 +225,7 @@ class PortfolioGrowthBloc try { final supportedCoins = await _removeUnsupportedCoins(event); final coins = await _removeInactiveCoins(supportedCoins); - return await portfolioGrowthRepository.getPortfolioGrowthChart( + return await _portfolioGrowthRepository.getPortfolioGrowthChart( coins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, @@ -244,7 +239,7 @@ class PortfolioGrowthBloc Future> _removeInactiveCoins(List coins) async { final coinsCopy = List.of(coins); - final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoins = await _sdk.assets.getActivatedAssets(); final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); for (final coin in coins) { if (!activeCoinsMap.contains(coin.id)) { @@ -283,7 +278,7 @@ class PortfolioGrowthBloc double _calculateTotalBalance(List coins) { double total = coins.fold( 0, - (prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0), + (prev, coin) => prev + (coin.lastKnownUsdBalance(_sdk) ?? 0), ); // Return at least 0.01 if total is positive but very small @@ -295,15 +290,14 @@ class PortfolioGrowthBloc } /// Calculate the total 24h change in USD value + /// TODO: look into avoiding zero default values here if no data is available Future _calculateTotalChange24h(List coins) async { Rational totalChange = Rational.zero; for (final coin in coins) { - final double usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0; + final double usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0; final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); - final price = portfolioGrowthRepository.getCachedPrice( - coin.id.symbol.configSymbol.toUpperCase(), - ); - final change24h = price?.change24h ?? Decimal.zero; + final change24h = + await _sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); } return totalChange; diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart index 16f71795ae..602cb5613a 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart @@ -13,7 +13,6 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; import 'package:logging/logging.dart'; -import 'package:rational/rational.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; @@ -22,7 +21,6 @@ import 'package:web_dex/bloc/cex_market_data/models/models.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/cache_miss_exception.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; -import 'package:web_dex/model/cex_price.dart' show CexPrice; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; @@ -555,39 +553,4 @@ class PortfolioGrowthRepository { } Future clearCache() => _graphCache.deleteAll(); - - /// Calculate the total 24h change in USD value for a list of coins - /// - /// This method fetches the current prices for all coins and calculates - /// the 24h change by multiplying each coin's percentage change by its USD balance - Future calculateTotalChange24h(List coins) async { - // Fetch current prices including 24h change data - final prices = await _coinsRepository.fetchCurrentPrices() ?? {}; - - // Calculate the 24h change by summing the change percentage of each coin - // multiplied by its USD balance and divided by 100 (to convert percentage to decimal) - Rational totalChange = Rational.zero; - for (final coin in coins) { - final price = prices[coin.id.symbol.configSymbol.toUpperCase()]; - final change24h = price?.change24h ?? Decimal.zero; - final usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0; - final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); - totalChange += (change24h * usdBalanceDecimal / Decimal.fromInt(100)); - } - return totalChange; - } - - /// Get the cached price for a given coin symbol - /// - /// This is used to avoid fetching prices for every calculation - CexPrice? getCachedPrice(String symbol) { - return _coinsRepository.getCachedPrice(symbol); - } - - /// Update prices for all coins by fetching from market data - /// - /// This method ensures we have up-to-date price data before calculations - Future updatePrices() async { - await _coinsRepository.fetchCurrentPrices(); - } } From 7b8df83345b6d90a63f8ed84789de35466f03def Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 13 Sep 2025 14:01:54 +0200 Subject: [PATCH 4/5] fix(coins-repo): use uppercase for access and parallelise price fetches --- lib/bloc/coins_bloc/coins_repo.dart | 101 +++++++++++------- .../common/grouped_asset_ticker_item.dart | 7 +- 2 files changed, 67 insertions(+), 41 deletions(-) diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 3d2ceb31f3..7b9c8fa76a 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:math' show min; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' @@ -45,7 +47,7 @@ class CoinsRepo { final Map> _addressCache = {}; // TODO: Remove since this is also being cached in the SDK - Map _pricesCache = {}; + final Map _pricesCache = {}; // Cache structure for storing balance information to reduce SDK calls // This is a temporary solution until the full migration to SDK is complete @@ -201,7 +203,7 @@ class CoinsRepo { Coin _assetToCoinWithoutAddress(Asset asset) { final coin = asset.toCoin(); final balanceInfo = _balancesCache[coin.id.id]; - final price = _pricesCache[coin.id.symbol.configSymbol]; + final price = _pricesCache[coin.id.symbol.configSymbol.toUpperCase()]; Coin? parentCoin; if (asset.id.isChildAsset) { @@ -534,11 +536,20 @@ class CoinsRepo { return parsedAmount * usdPrice; } + /// Fetches current prices for a broad set of assets + /// + /// This method is used to fetch prices for a broad set of assets so unauthenticated users + /// also see prices and 24h changes in lists and charts. + /// + /// Prefer activated assets if available (to limit requests when logged in), + /// otherwise fall back to all available SDK assets. Future?> fetchCurrentPrices() async { - // Fetch prices for a broad set of assets so unauthenticated users - // also see prices and 24h changes in lists and charts. - // Prefer activated assets if available (to limit requests when logged in), - // otherwise fall back to all available SDK assets. + // NOTE: key assumption here is that the Komodo Prices API supports most + // (ideally all) assets being requested, resulting in minimal requests to + // 3rd party fallback providers. If this assumption does not hold, then we + // will hit rate limits and have reduced market metrics functionality. + // This will happen regardless of chunk size. The rate limits are per IP + // per hour. final activatedAssets = await _kdfSdk.getWalletAssets(); final Iterable targetAssets = activatedAssets.isNotEmpty ? activatedAssets @@ -548,37 +559,56 @@ class CoinsRepo { // to have valid prices available at any of the providers final validAssets = targetAssets .where((asset) => !excludedAssetList.contains(asset.id.id)) - .where((asset) => !asset.protocol.isTestnet); + .where((asset) => !asset.protocol.isTestnet) + .toList(); - for (final asset in validAssets) { - try { - // Use maybeFiatPrice to avoid errors for assets not tracked by CEX - final fiatPrice = await _kdfSdk.marketData.maybeFiatPrice(asset.id); - if (fiatPrice != null) { - // Use configSymbol to lookup for backwards compatibility with the old, - // string-based price list (and fallback) - Decimal? change24h; - try { - change24h = await _kdfSdk.marketData.priceChange24h(asset.id); - } catch (e) { - _log.warning('Failed to get 24h change for ${asset.id.id}: $e'); - // Continue without 24h change data - } + // Process assets with bounded parallelism to avoid overwhelming providers + await _fetchAssetPricesInChunks(validAssets); - final symbolKey = asset.id.symbol.configSymbol.toUpperCase(); - _pricesCache[symbolKey] = CexPrice( - assetId: asset.id, - price: fiatPrice, - lastUpdated: DateTime.now(), - change24h: change24h, - ); + return _pricesCache; + } + + /// Processes assets in chunks with bounded parallelism to avoid + /// overloading providers. + Future _fetchAssetPricesInChunks( + List assets, { + int chunkSize = 12, + }) async { + final boundedChunkSize = min(assets.length, chunkSize); + final chunks = assets.slices(boundedChunkSize); + + for (final chunk in chunks) { + await Future.wait(chunk.map(_fetchAssetPrice), eagerError: false); + } + } + + /// Fetches price data for a single asset and updates the cache + Future _fetchAssetPrice(Asset asset) async { + try { + // Use maybeFiatPrice to avoid errors for assets not tracked by CEX + final fiatPrice = await _kdfSdk.marketData.maybeFiatPrice(asset.id); + if (fiatPrice != null) { + // Use configSymbol to lookup for backwards compatibility with the old, + // string-based price list (and fallback) + Decimal? change24h; + try { + change24h = await _kdfSdk.marketData.priceChange24h(asset.id); + } catch (e) { + _log.warning('Failed to get 24h change for ${asset.id.id}: $e'); + // Continue without 24h change data } - } catch (e) { - _log.warning('Failed to get price for ${asset.id.id}: $e'); + + final symbolKey = asset.id.symbol.configSymbol.toUpperCase(); + _pricesCache[symbolKey] = CexPrice( + assetId: asset.id, + price: fiatPrice, + lastUpdated: DateTime.now(), + change24h: change24h, + ); } + } catch (e) { + _log.warning('Failed to get price for ${asset.id.id}: $e'); } - - return _pricesCache; } /// Updates balances for active coins by querying the SDK @@ -665,11 +695,4 @@ class CoinsRepo { return BlocResponse(result: withdrawDetails); } - - /// Get a cached price for a given coin symbol - /// - /// This returns the price from the cache without fetching new data - CexPrice? getCachedPrice(String symbol) { - return _pricesCache[symbol]; - } } diff --git a/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart b/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart index f2383fc91b..15ff7ef7de 100644 --- a/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart +++ b/lib/views/wallet/wallet_page/common/grouped_asset_ticker_item.dart @@ -122,8 +122,11 @@ class _GroupedAssetTickerItemState extends State { flex: 2, child: BlocBuilder( builder: (context, state) { - final formattedPrice = price?.price != null - ? priceFormatter.format(price!.price) + // Double conversion required to fix the + // `noSuchMethod` error in the `format` method. + final priceValue = price?.price?.toDouble(); + final formattedPrice = priceValue != null + ? priceFormatter.format(priceValue) : ''; return Text( formattedPrice, From cc89eef004dfc0517df8524235d4298729f1308e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Sep 2025 16:01:00 +0000 Subject: [PATCH 5/5] feat: Add coin progress counters to portfolio growth Co-authored-by: charl --- .../portfolio_growth_bloc.dart | 68 ++++++++++++++++++- .../portfolio_growth_state.dart | 43 +++++++++++- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index e2ee491eb4..0082d84a3f 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -55,6 +55,8 @@ class PortfolioGrowthBloc PortfolioGrowthPeriodChanged event, Emitter emit, ) { + final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = + _calculateCoinProgressCounters(event.coins); final currentState = state; if (currentState is PortfolioGrowthChartLoadSuccess) { emit( @@ -65,6 +67,9 @@ class PortfolioGrowthBloc totalBalance: currentState.totalBalance, totalChange24h: currentState.totalChange24h, percentageChange24h: currentState.percentageChange24h, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, isUpdating: true, ), ); @@ -73,11 +78,19 @@ class PortfolioGrowthBloc GrowthChartLoadFailure( error: currentState.error, selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, ), ); } else if (currentState is PortfolioGrowthChartUnsupported) { emit( - PortfolioGrowthChartUnsupported(selectedPeriod: event.selectedPeriod), + PortfolioGrowthChartUnsupported( + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), ); } else { emit(const PortfolioGrowthInitial()); @@ -103,8 +116,18 @@ class PortfolioGrowthBloc // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. if (coins.isEmpty && event.coins.length <= 1) { + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters(event.coins); return emit( - PortfolioGrowthChartUnsupported(selectedPeriod: event.selectedPeriod), + PortfolioGrowthChartUnsupported( + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), ); } @@ -163,10 +186,18 @@ class PortfolioGrowthBloc ); } catch (error, stackTrace) { _log.shout('Failed to load portfolio growth', error, stackTrace); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters(event.coins); emit( GrowthChartLoadFailure( error: TextError(error: 'Failed to load portfolio growth'), selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, ), ); } @@ -207,6 +238,9 @@ class PortfolioGrowthBloc final totalChange24h = await _calculateTotalChange24h(coins); final percentageChange24h = await _calculatePercentageChange24h(coins); + final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = + _calculateCoinProgressCounters(event.coins); + return PortfolioGrowthChartLoadSuccess( portfolioGrowth: chart, percentageIncrease: chart.percentageIncrease, @@ -214,6 +248,9 @@ class PortfolioGrowthBloc totalBalance: totalBalance, totalChange24h: totalChange24h.toDouble(), percentageChange24h: percentageChange24h.toDouble(), + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, isUpdating: false, ); } @@ -263,6 +300,9 @@ class PortfolioGrowthBloc final totalChange24h = await _calculateTotalChange24h(coins); final percentageChange24h = await _calculatePercentageChange24h(coins); + final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = + _calculateCoinProgressCounters(coins); + return PortfolioGrowthChartLoadSuccess( portfolioGrowth: growthChart, percentageIncrease: percentageIncrease, @@ -270,6 +310,9 @@ class PortfolioGrowthBloc totalBalance: totalBalance, totalChange24h: totalChange24h.toDouble(), percentageChange24h: percentageChange24h.toDouble(), + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, isUpdating: false, ); } @@ -319,4 +362,25 @@ class PortfolioGrowthBloc // Return the percentage change return (totalChange / totalBalanceRational) * Rational.fromInt(100); } + + /// Calculate progress counters for balances and fiat prices + /// - totalCoins: total coins being considered (input list length) + /// - coinsWithKnownBalance: number of coins with a known last balance + /// - coinsWithKnownBalanceAndFiat: number of coins with a known last balance and known fiat price + (int, int, int) _calculateCoinProgressCounters(List coins) { + int totalCoins = coins.length; + int withBalance = 0; + int withBalanceAndFiat = 0; + for (final coin in coins) { + final balanceKnown = _sdk.balances.lastKnown(coin.id) != null; + if (balanceKnown) { + withBalance++; + final priceKnown = _sdk.marketData.priceIfKnown(coin.id) != null; + if (priceKnown) { + withBalanceAndFiat++; + } + } + } + return (totalCoins, withBalance, withBalanceAndFiat); + } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart index bb2553976a..39dff4b302 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart @@ -22,6 +22,9 @@ final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { required this.totalBalance, required this.totalChange24h, required this.percentageChange24h, + required this.totalCoins, + required this.coinsWithKnownBalance, + required this.coinsWithKnownBalanceAndFiat, this.isUpdating = false, }); @@ -30,6 +33,9 @@ final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { final double totalBalance; final double totalChange24h; final double percentageChange24h; + final int totalCoins; + final int coinsWithKnownBalance; + final int coinsWithKnownBalanceAndFiat; final bool isUpdating; @override @@ -40,6 +46,9 @@ final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { totalBalance, totalChange24h, percentageChange24h, + totalCoins, + coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat, isUpdating, ]; } @@ -48,15 +57,43 @@ final class GrowthChartLoadFailure extends PortfolioGrowthState { const GrowthChartLoadFailure({ required this.error, required super.selectedPeriod, + required this.totalCoins, + required this.coinsWithKnownBalance, + required this.coinsWithKnownBalanceAndFiat, }); final BaseError error; + final int totalCoins; + final int coinsWithKnownBalance; + final int coinsWithKnownBalanceAndFiat; @override - List get props => [error, selectedPeriod]; + List get props => [ + error, + selectedPeriod, + totalCoins, + coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat, + ]; } final class PortfolioGrowthChartUnsupported extends PortfolioGrowthState { - const PortfolioGrowthChartUnsupported({required Duration selectedPeriod}) - : super(selectedPeriod: selectedPeriod); + const PortfolioGrowthChartUnsupported({ + required Duration selectedPeriod, + required this.totalCoins, + required this.coinsWithKnownBalance, + required this.coinsWithKnownBalanceAndFiat, + }) : super(selectedPeriod: selectedPeriod); + + final int totalCoins; + final int coinsWithKnownBalance; + final int coinsWithKnownBalanceAndFiat; + + @override + List get props => [ + selectedPeriod, + totalCoins, + coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat, + ]; }