diff --git a/packages/komodo_coins/lib/src/komodo_coins_base.dart b/packages/komodo_coins/lib/src/komodo_coins_base.dart index 28385e01..af44046a 100644 --- a/packages/komodo_coins/lib/src/komodo_coins_base.dart +++ b/packages/komodo_coins/lib/src/komodo_coins_base.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -import 'package:komodo_coins/src/config_transform.dart'; import 'package:komodo_coins/src/asset_filter.dart'; +import 'package:komodo_coins/src/config_transform.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; diff --git a/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart b/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart index 92789b17..fbf7d40d 100644 --- a/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart +++ b/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart @@ -55,6 +55,13 @@ class _AssetItemTrailing extends StatelessWidget { @override Widget build(BuildContext context) { + final isChildAsset = asset.id.isChildAsset; + + // Use the parent coin ticker for child assets so that token logos display + // the network they belong to (e.g. ETH for ERC20 tokens). + final protocolTicker = + isChildAsset ? asset.id.parentId?.id : asset.id.subClass.iconTicker; + return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -76,7 +83,7 @@ class _AssetItemTrailing extends StatelessWidget { CircleAvatar( radius: 12, foregroundImage: NetworkImage( - 'https://komodoplatform.github.io/coins/icons/${asset.id.subClass.iconTicker.toLowerCase()}.png', + 'https://komodoplatform.github.io/coins/icons/${protocolTicker?.toLowerCase()}.png', ), backgroundColor: Colors.white70, ), diff --git a/packages/komodo_defi_types/lib/src/assets/asset_id.dart b/packages/komodo_defi_types/lib/src/assets/asset_id.dart index de28a6c1..9c08dee2 100644 --- a/packages/komodo_defi_types/lib/src/assets/asset_id.dart +++ b/packages/komodo_defi_types/lib/src/assets/asset_id.dart @@ -22,7 +22,8 @@ class AssetId extends Equatable { ? null : knownIds?.singleWhere( (parent) => - parent.id == parentCoinTicker && parent.subClass == subClass, + parent.id == parentCoinTicker && + parent.subClass.canBeParentOf(subClass), ); return AssetId( @@ -127,7 +128,7 @@ class AssetId extends Equatable { }; @override - List get props => [id, subClass.formatted]; + List get props => [id, subClass.formatted, chainId.formattedChainId]; @override String toString() => 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 fb0abf14..7419e84d 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 @@ -14,7 +14,6 @@ enum CoinSubClass { smartChain, moonriver, ethereumClassic, - tendermintToken, ubiq, bep20, matic, @@ -22,6 +21,7 @@ enum CoinSubClass { smartBch, erc20, tendermint, + tendermintToken, krc20, ewt, hrc20, @@ -162,6 +162,25 @@ enum CoinSubClass { } } + /// Checks if this subclass can be a parent of the given child subclass + bool canBeParentOf(CoinSubClass child) { + // Tendermint tokens can be a child of Tendermint, but not the + // other way around. This allows Tendermint to be a parent + // while keeping the existing parent subclass check intact. + if (this == CoinSubClass.tendermint && + child == CoinSubClass.tendermintToken) { + return true; + } + + // For most cases, parent and child should have the same subclass + return this == child; + } + + /// Checks if this subclass can be a child of the given parent subclass + bool canBeChildOf(CoinSubClass parent) { + return parent.canBeParentOf(this); + } + // TODO: Consider if null or an empty string should be returned for // subclasses where they don't have a symbol used in coin IDs. String get formatted { diff --git a/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart b/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart index 10aa04f9..ec3f7b2f 100644 --- a/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart +++ b/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart @@ -80,7 +80,8 @@ abstract class ProtocolClass with ExplorerUrlMixin implements Equatable { // 'Unsupported protocol type: ${subClass.formatted}', // ), }; - } catch (e) { + } catch (e, s) { + if (kDebugMode) debugPrintStack(stackTrace: s); throw ProtocolParsingException(primaryType, e.toString()); } } 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 cd8e89f6..5a82ad69 100644 --- a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart +++ b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart @@ -40,8 +40,9 @@ class AssetIcon extends StatelessWidget { @override Widget build(BuildContext context) { + final disabledTheme = Theme.of(context).disabledColor; return Opacity( - opacity: suspended ? 0.4 : 1, + opacity: suspended ? disabledTheme.a : 1.0, child: SizedBox.square( dimension: size, child: _AssetIconResolver( @@ -98,6 +99,24 @@ class AssetIcon extends StatelessWidget { throwExceptions: throwExceptions, ); } + + /// Checks if the asset icon exists in the local assets or CDN **based solely + /// on the internal cache**. + /// + /// This method does **not** perform a live check. It only returns `true` if + /// the icon has previously been loaded or pre-cached + /// and its existence has been recorded in the internal `_assetExistenceCache` + /// If the icon has not yet been loaded or pre-cached, + /// this method will return `false` even if the icon actually exists. + /// + /// **Note:** The result depends entirely on prior caching or loading attempts + /// To ensure up-to-date results, call [precacheAssetIcon] + /// before using this method. + /// + /// Returns true if the icon is known to exist (per cache), false otherwise. + static bool assetIconExists(String assetIconId) { + return _AssetIconResolver.assetIconExists(assetIconId); + } } class _AssetIconResolver extends StatelessWidget { @@ -203,6 +222,11 @@ class _AssetIconResolver extends StatelessWidget { } } + static bool assetIconExists(String assetIconId) { + final resolver = _AssetIconResolver(assetId: assetIconId, size: 20); + return _assetExistenceCache[resolver._imagePath] ?? false; + } + @override Widget build(BuildContext context) { if (_customIconsCache.containsKey(_sanitizedId)) { diff --git a/packages/komodo_ui/lib/src/defi/asset/asset_logo.dart b/packages/komodo_ui/lib/src/defi/asset/asset_logo.dart index e4c73013..df65b3a9 100644 --- a/packages/komodo_ui/lib/src/defi/asset/asset_logo.dart +++ b/packages/komodo_ui/lib/src/defi/asset/asset_logo.dart @@ -8,12 +8,8 @@ import 'package:komodo_ui/src/defi/asset/asset_icon.dart'; /// Similar to the legacy CoinLogo, but built on top of the new [Asset] /// and [AssetIcon] APIs. class AssetLogo extends StatelessWidget { - /// Creates a new [AssetLogo] widget. + /// Creates a new [AssetLogo] widget from an [Asset] instance. /// - /// [asset] is the asset for which the logo should be displayed. - /// [isDisabled] indicates whether the asset is disabled, in which case - /// the icon will be displayed with reduced opacity. - /// [size] is the size of the main asset icon, defaulting to 41 pixels. /// Example usage: /// ```dart /// AssetLogo(asset) @@ -23,10 +19,57 @@ class AssetLogo extends StatelessWidget { this.size = 41, this.isDisabled = false, super.key, - }); + }) : _assetId = null, + _legacyTicker = null, + isBlank = false; + + /// Creates a logo directly from an [AssetId]. + const AssetLogo.ofId( + AssetId assetId, { + this.size = 41, + this.isDisabled = false, + super.key, + }) : asset = null, + _assetId = assetId, + _legacyTicker = null, + isBlank = false; + + /// Legacy constructor that accepts a raw ticker string. + /// + /// This mirrors [AssetIcon.ofTicker] and should only be used when an + /// [Asset] or [AssetId] instance isn't available. + const AssetLogo.ofTicker( + String ticker, { + this.size = 41, + this.isDisabled = false, + super.key, + }) : _legacyTicker = ticker, + asset = null, + _assetId = null, + isBlank = false; + + /// Creates a placeholder [AssetLogo] widget. + /// + /// This displays the default placeholder icon (monetization_on_outlined) + /// and should be used when no asset data is available or as a fallback. + /// + /// Set [isBlank] to true to display an empty circular container instead + /// of the default icon, similar to the legacy placeholder behavior. + const AssetLogo.placeholder({ + this.size = 41, + this.isDisabled = false, + this.isBlank = false, + super.key, + }) : asset = null, + _assetId = null, + _legacyTicker = null; /// Asset to display the logo for. - final Asset asset; + final Asset? asset; + + /// AssetId to display the logo for. + final AssetId? _assetId; + final String? _legacyTicker; /// Size of the main asset icon. final double size; @@ -35,17 +78,49 @@ class AssetLogo extends StatelessWidget { /// with reduced opacity. final bool isDisabled; + /// Whether to display a blank placeholder instead of the default icon. + /// Only used with the [AssetLogo.placeholder] constructor. + final bool isBlank; + @override Widget build(BuildContext context) { - // Existing komodo-wallet implementation doesn't show protocol icon - // for UTXO assets (like BTC, LTC, etc.) so we follow the same logic here. - final protocolTicker = asset.protocol.subClass.iconTicker; - final shouldShowProtocolIcon = asset.protocol.subClass != CoinSubClass.utxo; + final resolvedId = asset?.id ?? _assetId; + final resolvedTicker = _legacyTicker; + + // Handle placeholder case + if (resolvedId == null && resolvedTicker == null) { + return _AssetLogoPlaceholder( + isBlank: isBlank, + isDisabled: isDisabled, + size: size, + ); + } + + final isChildAsset = resolvedId?.isChildAsset ?? false; + + // Use the parent coin ticker for child assets so that token logos display + // the network they belong to (e.g. ETH for ERC20 tokens). + final protocolTicker = isChildAsset ? resolvedId?.parentId?.id : null; + final shouldShowProtocolIcon = isChildAsset && protocolTicker != null; + + final mainIcon = + resolvedId != null + ? AssetIcon(resolvedId, size: size, suspended: isDisabled) + : (resolvedTicker != null + ? AssetIcon.ofTicker( + resolvedTicker, + size: size, + suspended: isDisabled, + ) + : throw ArgumentError( + 'resolvedTicker cannot be null when both asset and ' + 'assetId are absent.', + )); return Stack( clipBehavior: Clip.none, children: [ - AssetIcon(asset.id, size: size, suspended: isDisabled), + mainIcon, if (shouldShowProtocolIcon) AssetProtocolIcon(protocolTicker: protocolTicker, logoSize: size), ], @@ -53,6 +128,43 @@ class AssetLogo extends StatelessWidget { } } +class _AssetLogoPlaceholder extends StatelessWidget { + const _AssetLogoPlaceholder({ + required this.isBlank, + required this.isDisabled, + required this.size, + }); + + final bool isBlank; + final bool isDisabled; + final double size; + + @override + Widget build(BuildContext context) { + if (isBlank) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + isDisabled + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.secondaryContainer, + ), + ); + } + return Icon( + Icons.monetization_on_outlined, + size: size, + color: + isDisabled + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.onSecondaryContainer, + ); + } +} + /// A widget that displays a protocol icon with a circular border and shadow, /// positioned absolutely within its parent widget. ///