diff --git a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart index 55099e2f..1c56da98 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart @@ -18,14 +18,16 @@ class ActivationManager { this._assetHistory, this._customTokenHistory, this._assetLookup, - this._balanceManager, - ); + this._balanceManager, { + required IAssetRefreshNotifier assetRefreshNotifier, + }) : _assetRefreshNotifier = assetRefreshNotifier; final ApiClient _client; final KomodoDefiLocalAuth _auth; final AssetHistoryStorage _assetHistory; final CustomAssetHistoryStorage _customTokenHistory; final IAssetLookup _assetLookup; + final IAssetRefreshNotifier _assetRefreshNotifier; final IBalanceManager _balanceManager; final _activationMutex = Mutex(); static const _operationTimeout = Duration(seconds: 30); @@ -39,12 +41,8 @@ class ActivationManager { .protect(operation) .timeout( _operationTimeout, - onTimeout: - () => - throw TimeoutException( - 'Operation timed out', - _operationTimeout, - ), + onTimeout: () => + throw TimeoutException('Operation timed out', _operationTimeout), ); } @@ -77,13 +75,10 @@ class ActivationManager { continue; } - final parentAsset = - group.parentId == null - ? null - : _assetLookup.fromId(group.parentId!) ?? - (throw StateError( - 'Parent asset ${group.parentId} not found', - )); + final parentAsset = group.parentId == null + ? null + : _assetLookup.fromId(group.parentId!) ?? + (throw StateError('Parent asset ${group.parentId} not found')); yield ActivationProgress( status: 'Starting activation for ${group.primary.id.name}...', @@ -139,14 +134,13 @@ class ActivationManager { /// Check if asset and its children are already activated Future _checkActivationStatus(_AssetGroup group) async { try { - final enabledCoins = - await _client.rpc.generalActivation.getEnabledCoins(); - final enabledAssetIds = - enabledCoins.result - .map((coin) => _assetLookup.findAssetsByConfigId(coin.ticker)) - .expand((assets) => assets) - .map((asset) => asset.id) - .toSet(); + final enabledCoins = await _client.rpc.generalActivation + .getEnabledCoins(); + final enabledAssetIds = enabledCoins.result + .map((coin) => _assetLookup.findAssetsByConfigId(coin.ticker)) + .expand((assets) => assets) + .map((asset) => asset.id) + .toSet(); final isActive = enabledAssetIds.contains(group.primary.id); final childrenActive = @@ -211,6 +205,11 @@ class ActivationManager { // Pre-cache balance for the activated asset await _balanceManager.precacheBalance(asset); } + + // Notify asset manager to refresh custom tokens if any were activated + if (allAssets.any((asset) => asset.protocol.isCustomToken)) { + _assetRefreshNotifier.notifyCustomTokensChanged(); + } } if (!completer.isCompleted) { @@ -237,8 +236,8 @@ class ActivationManager { } try { - final enabledCoins = - await _client.rpc.generalActivation.getEnabledCoins(); + final enabledCoins = await _client.rpc.generalActivation + .getEnabledCoins(); return enabledCoins.result .map((coin) => _assetLookup.findAssetsByConfigId(coin.ticker)) .expand((assets) => assets) diff --git a/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart b/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart index 11583d21..abf0ad63 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart @@ -25,3 +25,10 @@ abstract class IAssetProvider extends IAssetLookup { /// Get list of enabled coin tickers Future> getEnabledCoins(); } + +/// Interface for notifying about asset changes that require UI refresh +// ignore: one_member_abstracts +abstract interface class IAssetRefreshNotifier { + /// Notifies that custom tokens have changed and should be refreshed + void notifyCustomTokensChanged(); +} diff --git a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart index 7033b5a8..2be1e544 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart @@ -1,9 +1,10 @@ -// lib/src/assets/asset_manager.dart - -import 'dart:async'; +// TODO: refactor to rely on komodo_coins cache instead of duplicating the +// splaytreemap cache here. This turns it into a thinner wrapper than it +// already is. +import 'dart:async' show StreamSubscription, unawaited; import 'dart:collection'; -import 'package:flutter/foundation.dart' show ValueGetter; +import 'package:flutter/foundation.dart' show ValueGetter, debugPrint; import 'package:komodo_coins/komodo_coins.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; @@ -42,7 +43,7 @@ typedef AssetIdMap = SplayTreeMap; /// /// The manager listens to authentication changes to keep the available asset /// list in sync with the active wallet's capabilities. -class AssetManager implements IAssetProvider { +class AssetManager implements IAssetProvider, IAssetRefreshNotifier { /// Creates a new instance of AssetManager. /// /// This is typically created by the SDK and shouldn't need to be instantiated @@ -93,7 +94,7 @@ class AssetManager implements IAssetProvider { _refreshCoins(const NoAssetFilterStrategy()); - await _initializeCustomTokens(); + await _refreshCustomTokens(); } /// Exposes the currently active commit hash for coins config. @@ -103,11 +104,9 @@ class AssetManager implements IAssetProvider { Future get latestCoinsCommit async => _coins.getLatestCommitHash(); void _refreshCoins(AssetFilterStrategy strategy) { - if (_currentFilterStrategy?.strategyId == strategy.strategyId) return; _orderedCoins ..clear() ..addAll(_coins.filteredAssets(strategy)); - _currentFilterStrategy = strategy; } /// Applies a new [strategy] for filtering available assets. @@ -115,20 +114,40 @@ class AssetManager implements IAssetProvider { /// This is called whenever the authentication state changes so the /// visible asset list always matches the capabilities of the active wallet. void setFilterStrategy(AssetFilterStrategy strategy) { + if (_currentFilterStrategy?.strategyId == strategy.strategyId) return; + + _currentFilterStrategy = strategy; if (_coins.isInitialized) { _refreshCoins(strategy); + // Also refresh custom tokens to apply the new filter strategy + unawaited(_refreshCustomTokens()); } } - Future _initializeCustomTokens() async { + Future _refreshCustomTokens() async { final user = await _auth.currentUser; - if (user != null) { - final customTokens = await _customAssetHistory.getWalletAssets( - user.walletId, - ); - for (final customToken in customTokens) { - _orderedCoins[customToken.id] = customToken; - } + if (user == null) { + debugPrint('No user signed in, skipping custom token refresh'); + return; + } + + // Drop previously injected custom tokens to avoid stale entries + final toRemove = []; + _orderedCoins.forEach((id, asset) { + if (asset.protocol.isCustomToken) toRemove.add(id); + }); + for (final id in toRemove) { + _orderedCoins.remove(id); + } + + final customTokens = await _customAssetHistory.getWalletAssets( + user.walletId, + ); + + final filteredCustomTokens = _filterCustomTokens(customTokens); + + for (final customToken in filteredCustomTokens) { + _orderedCoins[customToken.id] = customToken; } } @@ -146,10 +165,9 @@ class AssetManager implements IAssetProvider { // Trezor does not support all assets yet, so we apply a filter here // to only show assets that are compatible with Trezor. // WalletConnect and Metamask will require similar handling in the future. - final strategy = - isTrezor - ? const TrezorAssetFilterStrategy(hiddenAssets: {'BCH'}) - : const NoAssetFilterStrategy(); + final strategy = isTrezor + ? const TrezorAssetFilterStrategy(hiddenAssets: {'BCH'}) + : const NoAssetFilterStrategy(); setFilterStrategy(strategy); } @@ -159,19 +177,18 @@ class AssetManager implements IAssetProvider { /// Returns null if no matching asset is found. /// Throws [StateError] if called before initialization. @override - Asset? fromId(AssetId id) => - _coins.isInitialized - ? available[id] - : throw StateError( - 'Assets have not been initialized. Call init() first.', - ); + Asset? fromId(AssetId id) => _coins.isInitialized + ? available[id] + : throw StateError( + 'Assets have not been initialized. Call init() first.', + ); /// Returns all available assets, ordered by priority. /// /// Default assets (configured in [KomodoDefiSdkConfig]) appear first, /// followed by other assets in alphabetical order. @override - Map get available => _orderedCoins; + Map get available => Map.unmodifiable(_orderedCoins); Map get availableOrdered => available; /// Returns currently activated assets for the signed-in user. @@ -205,7 +222,9 @@ class AssetManager implements IAssetProvider { /// ``` @override Set findAssetsByConfigId(String ticker) { - return available.values.where((asset) => asset.id.id == ticker).toSet(); + // Create a defensive copy to prevent concurrent modification during iteration + final assetsCopy = List.of(_orderedCoins.values); + return assetsCopy.where((asset) => asset.id.id == ticker).toSet(); } /// Returns child assets for the given parent asset ID. @@ -219,7 +238,9 @@ class AssetManager implements IAssetProvider { /// ``` @override Set childAssetsOf(AssetId parentId) { - return available.values + // Create a defensive copy to prevent concurrent modification during iteration + final assetsCopy = List.of(_orderedCoins.values); + return assetsCopy .where( (asset) => asset.id.isChildAsset && asset.id.parentId == parentId, ) @@ -252,6 +273,30 @@ class AssetManager implements IAssetProvider { Stream activateAssets(List assets) => _activationManager().activateAssets(assets); + @override + void notifyCustomTokensChanged() { + // Refresh custom tokens when notified by the activation manager + unawaited( + _refreshCustomTokens().catchError((Object e, StackTrace s) { + debugPrint('Custom token refresh failed: $e'); + }), + ); + } + + /// Filters custom tokens based on the current asset filtering strategy. + /// + /// Custom tokens don't have traditional coin configs, so we create a minimal + /// config structure to support filtering decisions. This ensures custom tokens + /// are properly filtered alongside regular assets. + Set _filterCustomTokens(Set customTokens) { + final strategy = _currentFilterStrategy; + if (strategy == null) return customTokens; + + return customTokens.where((Asset token) { + return strategy.shouldInclude(token, token.protocol.config); + }).toSet(); + } + /// Disposes of the asset manager, cleaning up resources. /// /// This is called automatically by the SDK when disposing. diff --git a/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart b/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart index c5592494..b556860a 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart @@ -11,7 +11,12 @@ class CustomAssetHistoryStorage { /// Store custom tokens used by a wallet Future storeWalletAssets(WalletId walletId, Set assets) async { final key = _getStorageKey(walletId); - final assetsJsonArray = assets.map((asset) => asset.toJson()).toList(); + // Use the protocol config instead of asset toJson due to missing fields + // from the incomplete Asset.toJson implementation. Similar to the + // komodo_coin_updates/hive/hive_adapters.dart issue. + final assetsJsonArray = assets + .map((asset) => asset.protocol.config) + .toList(); await _storage.write(key: key, value: assetsJsonArray.toJsonString()); } diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index bf5b0d82..1f65d9e3 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -109,6 +109,8 @@ Future bootstrap({ container(), assetManager, balanceManager, + // Separate interface used to avoid muddying the IAssetProvider interface + assetRefreshNotifier: assetManager, ); return activationManager; @@ -135,8 +137,8 @@ Future bootstrap({ container.registerSingletonAsync(() async { final client = await container.getAsync(); final auth = await container.getAsync(); - final activationCoordinator = - await container.getAsync(); + final activationCoordinator = await container + .getAsync(); final pubkeyManager = PubkeyManager(client, auth, activationCoordinator); // Set the PubkeyManager on BalanceManager now that it's available @@ -193,8 +195,8 @@ Future bootstrap({ final auth = await container.getAsync(); final assetProvider = await container.getAsync(); final pubkeys = await container.getAsync(); - final activationCoordinator = - await container.getAsync(); + final activationCoordinator = await container + .getAsync(); return TransactionHistoryManager( client, auth, @@ -218,8 +220,8 @@ Future bootstrap({ final assetProvider = await container.getAsync(); final feeManager = await container.getAsync(); - final activationCoordinator = - await container.getAsync(); + final activationCoordinator = await container + .getAsync(); return WithdrawalManager( client, assetProvider, @@ -240,8 +242,8 @@ Future bootstrap({ final client = await container.getAsync(); final auth = await container.getAsync(); final assetProvider = await container.getAsync(); - final activationCoordinator = - await container.getAsync(); + final activationCoordinator = await container + .getAsync(); return SecurityManager( client, auth, diff --git a/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart b/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart index 3411ae9b..ea8a97b5 100644 --- a/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart +++ b/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart @@ -142,16 +142,43 @@ enum CoinSubClass { } } - // Parse + /// Parse a string to a coin subclass. + /// + /// Attempts to match the string to a coin subclass by: + /// - Partial match to the subclass name + /// - Partial match to the subclass ticker + /// - Partial match to the subclass token standard suffix + /// - Partial match to the subclass formatted name + /// + /// Throws [StateError] if no match is found. static CoinSubClass parse(String value) { const filteredChars = ['_', '-', ' ']; final regex = RegExp('(${filteredChars.join('|')})'); final sanitizedValue = value.toLowerCase().replaceAll(regex, ''); - return CoinSubClass.values.firstWhere( - (e) => e.toString().toLowerCase().contains(sanitizedValue), - ); + return CoinSubClass.values.firstWhere((e) { + // Exit early if exact match to default to previous behavior and avoid + // unnecessary checks. + final matchesValue = e.toString().toLowerCase().contains(sanitizedValue); + if (matchesValue) { + return true; + } + + final matchesTicker = e.ticker.toLowerCase().contains(sanitizedValue); + if (matchesTicker) { + return true; + } + + final matchesTokenStandardSuffix = + e.tokenStandardSuffix?.toLowerCase().contains(sanitizedValue) ?? + false; + if (matchesTokenStandardSuffix) { + return true; + } + + return e.formatted.toLowerCase().contains(sanitizedValue); + }); } static CoinSubClass? tryParse(String value) { diff --git a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart index f9f59d17..c729b065 100644 --- a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart +++ b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart @@ -52,15 +52,15 @@ class AssetIcon extends StatelessWidget { size: size, ), ); - + // Apply opacity first for disabled state icon = Opacity(opacity: suspended ? disabledTheme.a : 1.0, child: icon); - + // Then wrap with Hero widget if provided (Hero should be outermost) if (heroTag != null) { icon = Hero(tag: heroTag!, child: icon); } - + return icon; } @@ -148,7 +148,8 @@ class _AssetIconResolver extends StatelessWidget { static final Map _customIconsCache = {}; static void registerCustomIcon(AssetId assetId, ImageProvider imageProvider) { - _customIconsCache[assetId.symbol.configSymbol] = imageProvider; + final sanitizedId = assetId.symbol.configSymbol.toLowerCase(); + _customIconsCache[sanitizedId] = imageProvider; } static void clearCaches() { @@ -171,10 +172,10 @@ class _AssetIconResolver extends StatelessWidget { final sanitizedId = resolver._sanitizedId; try { - if (_customIconsCache.containsKey(asset.symbol.configSymbol)) { + if (_customIconsCache.containsKey(sanitizedId)) { if (context.mounted) { await precacheImage( - _customIconsCache[asset.symbol.configSymbol]!, + _customIconsCache[sanitizedId]!, context, onError: (e, stackTrace) { if (throwExceptions) {