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 48a8d854..1e3c5df1 100644 --- a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart +++ b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show AssetManifest, rootBundle; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// A widget that displays an icon for a given [AssetId]. @@ -159,6 +160,10 @@ class _AssetIconResolver extends StatelessWidget { static final Map _assetExistenceCache = {}; static final Map _cdnExistenceCache = {}; static final Map _customIconsCache = {}; + static final Map _lastCdnFailureAt = {}; + static Set? _bundledAssetPaths; + static Future>? _bundledAssetPathsLoader; + static const _cdnRetryInterval = Duration(minutes: 1); static void registerCustomIcon(AssetId assetId, ImageProvider imageProvider) { final sanitizedId = assetId.symbol.configSymbol.toLowerCase(); @@ -169,6 +174,9 @@ class _AssetIconResolver extends StatelessWidget { _assetExistenceCache.clear(); _cdnExistenceCache.clear(); _customIconsCache.clear(); + _lastCdnFailureAt.clear(); + _bundledAssetPaths = null; + _bundledAssetPathsLoader = null; } String get _sanitizedId => @@ -176,19 +184,63 @@ class _AssetIconResolver extends StatelessWidget { String get _imagePath => '$_coinImagesFolder$_sanitizedId.png'; String get _cdnUrl => '$_mediaCdnUrl$_sanitizedId.png'; + static Future> _loadBundledAssetPaths() async { + if (_bundledAssetPaths != null) { + return _bundledAssetPaths!; + } + + if (_bundledAssetPathsLoader != null) { + return _bundledAssetPathsLoader!; + } + + _bundledAssetPathsLoader = () async { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + return manifest.listAssets().toSet(); + }(); + + try { + _bundledAssetPaths = await _bundledAssetPathsLoader; + return _bundledAssetPaths!; + } finally { + _bundledAssetPathsLoader = null; + } + } + + static Future _isBundledAssetDeclared(String assetPath) async { + try { + final bundledPaths = await _loadBundledAssetPaths(); + return bundledPaths.contains(assetPath); + } catch (e) { + debugPrint('Failed to load asset manifest for icon precache: $e'); + return null; + } + } + static Future _didImagePrecacheSucceed( ImageProvider image, BuildContext context, ) async { final outcome = _PrecacheOutcome(); - await precacheImage( - image, - context, - onError: outcome.recordFailure, - ); + await precacheImage(image, context, onError: outcome.recordFailure); return outcome.succeeded; } + static Future _precacheCdnImage( + BuildContext context, + NetworkImage cdnImage, + String sanitizedId, + ) async { + if (!context.mounted) return false; + final cdnSucceeded = await _didImagePrecacheSucceed(cdnImage, context); + _cdnExistenceCache[sanitizedId] = cdnSucceeded; + if (cdnSucceeded) { + _lastCdnFailureAt.remove(sanitizedId); + } else { + _lastCdnFailureAt[sanitizedId] = DateTime.now(); + } + return cdnSucceeded; + } + static Future precacheAssetIcon( BuildContext context, AssetId asset, { @@ -199,42 +251,61 @@ class _AssetIconResolver extends StatelessWidget { try { if (_customIconsCache.containsKey(sanitizedId)) { - if (context.mounted) { - await precacheImage( - _customIconsCache[sanitizedId]!, - context, - onError: (e, stackTrace) { - if (throwExceptions) { - throw Exception( - 'Failed to pre-cache custom image for coin $asset: $e', - ); - } - }, - ); + if (!context.mounted) return; + + final customSucceeded = await _didImagePrecacheSucceed( + _customIconsCache[sanitizedId]!, + context, + ); + if (throwExceptions && !customSucceeded) { + throw Exception('Failed to pre-cache custom image for coin $asset.'); } return; } final assetImage = AssetImage(resolver._imagePath); final cdnImage = NetworkImage(resolver._cdnUrl); + final bundledAssetExists = await _isBundledAssetDeclared( + resolver._imagePath, + ); - final assetSucceeded = await _didImagePrecacheSucceed(assetImage, context); - _assetExistenceCache[resolver._imagePath] = assetSucceeded; + if (bundledAssetExists == true || bundledAssetExists == null) { + if (!context.mounted) return; + final assetSucceeded = await _didImagePrecacheSucceed( + assetImage, + context, + ); + _assetExistenceCache[resolver._imagePath] = assetSucceeded; + if (assetSucceeded) { + _cdnExistenceCache.remove(sanitizedId); + _lastCdnFailureAt.remove(sanitizedId); + return; + } - if (assetSucceeded) { + _assetExistenceCache[resolver._imagePath] = false; + if (!context.mounted) return; + final cdnSucceeded = await _precacheCdnImage( + context, + cdnImage, + sanitizedId, + ); + if (throwExceptions && !cdnSucceeded) { + throw Exception( + 'Failed to pre-cache bundled and CDN images for asset ${asset.id}', + ); + } return; } - final cdnSucceeded = - context.mounted && await _didImagePrecacheSucceed(cdnImage, context); - if (context.mounted) { - _cdnExistenceCache[sanitizedId] = cdnSucceeded; - } - + _assetExistenceCache[resolver._imagePath] = false; + if (!context.mounted) return; + final cdnSucceeded = await _precacheCdnImage( + context, + cdnImage, + sanitizedId, + ); if (throwExceptions && !cdnSucceeded) { - throw Exception( - 'Failed to pre-cache bundled and CDN images for asset ${asset.id}', - ); + throw Exception('Failed to pre-cache CDN image for asset ${asset.id}'); } } catch (e) { debugPrint('Error in precacheAssetIcon for ${asset.id}: $e'); @@ -247,6 +318,28 @@ class _AssetIconResolver extends StatelessWidget { return _assetExistenceCache[resolver._imagePath] ?? false; } + Widget _buildFallbackIcon() { + return Icon(Icons.monetization_on_outlined, size: size); + } + + Widget _buildCdnImage() { + return Image.network( + _cdnUrl, + filterQuality: FilterQuality.high, + errorBuilder: (context, error, stackTrace) { + _cdnExistenceCache[_sanitizedId] = false; + _lastCdnFailureAt[_sanitizedId] = DateTime.now(); + return _buildFallbackIcon(); + }, + ); + } + + bool _shouldRetryCdnNow() { + final lastFailure = _lastCdnFailureAt[_sanitizedId]; + if (lastFailure == null) return true; + return DateTime.now().difference(lastFailure) >= _cdnRetryInterval; + } + @override Widget build(BuildContext context) { if (_customIconsCache.containsKey(_sanitizedId)) { @@ -260,22 +353,34 @@ class _AssetIconResolver extends StatelessWidget { ); } - _assetExistenceCache[_imagePath] = true; + final bundledState = _assetExistenceCache[_imagePath]; + final cdnState = _cdnExistenceCache[_sanitizedId]; + + if (bundledState == false && cdnState == true) { + return _buildCdnImage(); + } + + if (bundledState == false && cdnState == false) { + if (_shouldRetryCdnNow()) { + _cdnExistenceCache[_sanitizedId] = true; + return _buildCdnImage(); + } + return _buildFallbackIcon(); + } + + _assetExistenceCache[_imagePath] = bundledState ?? true; return Image.asset( _imagePath, filterQuality: FilterQuality.high, errorBuilder: (context, error, stackTrace) { _assetExistenceCache[_imagePath] = false; + if (_cdnExistenceCache[_sanitizedId] == false) { + return _buildFallbackIcon(); + } + _cdnExistenceCache[_sanitizedId] ??= true; - return Image.network( - _cdnUrl, - filterQuality: FilterQuality.high, - errorBuilder: (context, error, stackTrace) { - _cdnExistenceCache[_sanitizedId] = false; - return Icon(Icons.monetization_on_outlined, size: size); - }, - ); + return _buildCdnImage(); }, ); }