diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 6c34226232..923f77a49b 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -66,6 +66,7 @@ import 'package:web_dex/router/parsers/root_route_parser.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/services/orders_service/my_orders_service.dart'; import 'package:web_dex/shared/utils/debug_utils.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; import 'package:web_dex/shared/utils/utils.dart'; class AppBlocRoot extends StatelessWidget { @@ -86,8 +87,9 @@ class AppBlocRoot extends StatelessWidget { ) async { final sharedPrefs = await SharedPreferences.getInstance(); - final storedLastPerformanceMode = - sharedPrefs.getString('last_performance_mode'); + final storedLastPerformanceMode = sharedPrefs.getString( + 'last_performance_mode', + ); if (storedLastPerformanceMode != performanceMode?.name) { profitLossRepo.clearCache().ignore(); @@ -152,11 +154,14 @@ class AppBlocRoot extends StatelessWidget { return MultiRepositoryProvider( providers: [ + // Keep ipfs gateway manager near root to keep in-memory cache of failing + // URLS to avoid repeated requests to the same failing URLs. RepositoryProvider( - create: (_) => NftsRepo( - api: mm2Api.nft, - coinsRepo: coinsRepository, - ), + create: (_) => IpfsGatewayManager(), + dispose: (manager) => manager.dispose(), + ), + RepositoryProvider( + create: (_) => NftsRepo(api: mm2Api.nft, coinsRepo: coinsRepository), ), RepositoryProvider(create: (_) => tradingEntitiesBloc), RepositoryProvider(create: (_) => dexRepository), @@ -178,21 +183,17 @@ class AppBlocRoot extends StatelessWidget { child: MultiBlocProvider( providers: [ BlocProvider( - create: (context) => CoinsBloc( - komodoDefiSdk, - coinsRepository, - )..add(CoinsStarted()), + create: (context) => + CoinsBloc(komodoDefiSdk, coinsRepository)..add(CoinsStarted()), ), BlocProvider( - create: (context) => PriceChartBloc( - binanceRepository, - komodoDefiSdk, - )..add( - const PriceChartStarted( - symbols: ['BTC'], - period: Duration(days: 30), + create: (context) => + PriceChartBloc(binanceRepository, komodoDefiSdk)..add( + const PriceChartStarted( + symbols: ['BTC'], + period: Duration(days: 30), + ), ), - ), ), BlocProvider( create: (context) => AssetOverviewBloc( @@ -202,10 +203,7 @@ class AppBlocRoot extends StatelessWidget { ), ), BlocProvider( - create: (context) => ProfitLossBloc( - profitLossRepo, - komodoDefiSdk, - ), + create: (context) => ProfitLossBloc(profitLossRepo, komodoDefiSdk), ), BlocProvider( create: (BuildContext ctx) => PortfolioGrowthBloc( @@ -214,9 +212,8 @@ class AppBlocRoot extends StatelessWidget { ), ), BlocProvider( - create: (BuildContext ctx) => TransactionHistoryBloc( - sdk: komodoDefiSdk, - ), + create: (BuildContext ctx) => + TransactionHistoryBloc(sdk: komodoDefiSdk), ), BlocProvider( create: (context) => @@ -252,10 +249,8 @@ class AppBlocRoot extends StatelessWidget { ), BlocProvider( lazy: false, - create: (context) => NftMainBloc( - repo: context.read(), - sdk: komodoDefiSdk, - ), + create: (context) => + NftMainBloc(repo: context.read(), sdk: komodoDefiSdk), ), if (isBitrefillIntegrationEnabled) BlocProvider( @@ -264,10 +259,7 @@ class AppBlocRoot extends StatelessWidget { ), BlocProvider( create: (context) => MarketMakerBotBloc( - MarketMakerBotRepository( - mm2Api, - SettingsRepository(), - ), + MarketMakerBotRepository(mm2Api, SettingsRepository()), MarketMakerBotOrderListRepository( myOrdersService, SettingsRepository(), @@ -277,13 +269,14 @@ class AppBlocRoot extends StatelessWidget { ), BlocProvider( lazy: false, - create: (context) => TradingStatusBloc( - context.read(), - )..add(TradingStatusCheckRequested()), + create: (context) => + TradingStatusBloc(context.read()) + ..add(TradingStatusCheckRequested()), ), BlocProvider( - create: (_) => SystemHealthBloc(SystemClockRepository(), mm2Api) - ..add(SystemHealthPeriodicCheckStarted()), + create: (_) => + SystemHealthBloc(SystemClockRepository(), mm2Api) + ..add(SystemHealthPeriodicCheckStarted()), ), BlocProvider( create: (context) => CoinsManagerBloc( @@ -347,8 +340,9 @@ class _MyAppViewState extends State<_MyAppView> { Widget build(BuildContext context) { return MaterialApp.router( onGenerateTitle: (_) => appTitle, - themeMode: context - .select((SettingsBloc settingsBloc) => settingsBloc.state.themeMode), + themeMode: context.select( + (SettingsBloc settingsBloc) => settingsBloc.state.themeMode, + ), darkTheme: theme.global.dark, theme: theme.global.light, routerDelegate: _routerDelegate, @@ -388,15 +382,16 @@ class _MyAppViewState extends State<_MyAppView> { // Remove the loading indicator. loadingElement.remove(); - final delay = - DateTime.now().difference(_pageLoadStartTime).inMilliseconds; + final delay = DateTime.now() + .difference(_pageLoadStartTime) + .inMilliseconds; context.read().logEvent( - PageInteractiveDelayEventData( - pageName: 'app_root', - interactiveDelayMs: delay, - spinnerTimeMs: 200, - ), - ); + PageInteractiveDelayEventData( + pageName: 'app_root', + interactiveDelayMs: delay, + spinnerTimeMs: 200, + ), + ); } } @@ -430,20 +425,22 @@ class _MyAppViewState extends State<_MyAppView> { // } // ignore: use_build_context_synchronously - await AssetIcon.precacheAssetIcon(context, assetId).onError( - (_, __) => debugPrint('Error precaching coin icon $assetId')); + await AssetIcon.precacheAssetIcon( + context, + assetId, + ).onError((_, __) => debugPrint('Error precaching coin icon $assetId')); } _currentPrecacheOperation!.complete(); if (!mounted) return; context.read().logEvent( - CoinsDataUpdatedEventData( - updateSource: 'remote', - updateDurationMs: stopwatch.elapsedMilliseconds, - coinsCount: availableAssetIds.length, - ), - ); + CoinsDataUpdatedEventData( + updateSource: 'remote', + updateDurationMs: stopwatch.elapsedMilliseconds, + coinsCount: availableAssetIds.length, + ), + ); } catch (e) { log('Error precaching coin icons: $e'); _currentPrecacheOperation!.completeError(e); diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index cd2620ea18..69acd8ad73 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -30,11 +30,9 @@ 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, - }) : _kdfSdk = kdfSdk, - _mm2 = mm2 { + CoinsRepo({required KomodoDefiSdk kdfSdk, required MM2 mm2}) + : _kdfSdk = kdfSdk, + _mm2 = mm2 { enabledAssetsChanges = StreamController.broadcast( onListen: () => _enabledAssetListenerCount += 1, onCancel: () => _enabledAssetListenerCount -= 1, @@ -90,14 +88,15 @@ class CoinsRepo { _balanceWatchers[asset.id]?.cancel(); // Start a new subscription - _balanceWatchers[asset.id] = - _kdfSdk.balances.watchBalance(asset.id).listen((balanceInfo) { - // Update the balance cache with the new values - _balancesCache[asset.id.id] = ( - balance: balanceInfo.total.toDouble(), - spendable: balanceInfo.spendable.toDouble(), - ); - }); + _balanceWatchers[asset.id] = _kdfSdk.balances.watchBalance(asset.id).listen( + (balanceInfo) { + // Update the balance cache with the new values + _balancesCache[asset.id.id] = ( + balance: balanceInfo.total.toDouble(), + spendable: balanceInfo.spendable.toDouble(), + ); + }, + ); } void flushCache() { @@ -172,8 +171,10 @@ class CoinsRepo { } } - @Deprecated('Use KomodoDefiSdk assets or the ' - 'Wallet [KdfUser].wallet extension instead.') + @Deprecated( + 'Use KomodoDefiSdk assets or the ' + 'Wallet [KdfUser].wallet extension instead.', + ) Future> getWalletCoins() async { final currentUser = await _kdfSdk.auth.currentUser; if (currentUser == null) { @@ -181,22 +182,20 @@ class CoinsRepo { } return currentUser.wallet.config.activatedCoins - .map( - (coinId) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning('No assets found for coinId: $coinId'); - return null; - } - if (assets.length > 1) { - _log.shout( - 'Multiple assets found for coinId: $coinId (${assets.length} assets). ' - 'Selecting the first asset: ${assets.first.id.id}', - ); - } - return assets.single; - }, - ) + .map((coinId) { + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) { + _log.warning('No assets found for coinId: $coinId'); + return null; + } + if (assets.length > 1) { + _log.shout( + 'Multiple assets found for coinId: $coinId (${assets.length} assets). ' + 'Selecting the first asset: ${assets.first.id.id}', + ); + } + return assets.single; + }) .whereType() .map(_assetToCoinWithoutAddress) .toList(); @@ -278,9 +277,7 @@ class CoinsRepo { final isSignedIn = await _kdfSdk.auth.isSignedIn(); if (!isSignedIn) { final coinIdList = assets.map((e) => e.id.id).join(', '); - _log.warning( - 'No wallet signed in. Skipping activation of [$coinIdList]', - ); + _log.warning('No wallet signed in. Skipping activation of [$coinIdList]'); return; } @@ -313,8 +310,9 @@ class CoinsRepo { final progress = await _kdfSdk.assets.activateAsset(asset).last; if (!progress.isSuccess) { - throw Exception(progress.errorMessage ?? - 'Activation failed for ${asset.id.id}'); + throw Exception( + progress.errorMessage ?? 'Activation failed for ${asset.id.id}', + ); } }, maxAttempts: maxRetryAttempts, @@ -329,7 +327,8 @@ class CoinsRepo { _broadcastAsset(coin.copyWith(state: CoinState.active)); if (coin.id.parentId != null) { final parentCoin = _assetToCoinWithoutAddress( - _kdfSdk.assets.available[coin.id.parentId]!); + _kdfSdk.assets.available[coin.id.parentId]!, + ); _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); } } @@ -345,11 +344,12 @@ class CoinsRepo { } catch (e, s) { lastActivationException = e is Exception ? e : Exception(e.toString()); _log.shout( - 'Error activating asset after retries: ${asset.id.id}', e, s); + 'Error activating asset after retries: ${asset.id.id}', + e, + s, + ); if (notify) { - _broadcastAsset( - asset.toCoin().copyWith(state: CoinState.suspended), - ); + _broadcastAsset(asset.toCoin().copyWith(state: CoinState.suspended)); } } finally { // Register outside of the try-catch to ensure icon is available even @@ -513,8 +513,10 @@ class CoinsRepo { } } - @Deprecated('Use SDK pubkeys.getPubkeys instead and let the user ' - 'select from the available options.') + @Deprecated( + 'Use SDK pubkeys.getPubkeys instead and let the user ' + 'select from the available options.', + ) Future getFirstPubkey(String coinId) async { final asset = _kdfSdk.assets.findAssetsByConfigId(coinId).single; final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); @@ -539,7 +541,12 @@ class CoinsRepo { try { // Try to use the SDK's price manager to get prices for active coins final activatedAssets = await _kdfSdk.assets.getActivatedAssets(); - for (final asset in activatedAssets) { + // Filter out excluded and testnet assets, as they are not expected + // to have valid prices available at any of the providers + final validActivatedAssets = activatedAssets + .where((asset) => !excludedAssetList.contains(asset.id.id)) + .where((asset) => !asset.protocol.isTestnet); + for (final asset in validActivatedAssets) { try { // Use maybeFiatPrice to avoid errors for assets not tracked by CEX final fiatPrice = await _kdfSdk.marketData.maybeFiatPrice(asset.id); @@ -548,13 +555,15 @@ class CoinsRepo { // string-based price list (and fallback) double? change24h; try { - final change24hDecimal = await _kdfSdk.marketData.priceChange24h(asset.id); + final change24hDecimal = await _kdfSdk.marketData.priceChange24h( + asset.id, + ); change24h = change24hDecimal?.toDouble(); } catch (e) { _log.warning('Failed to get 24h change for ${asset.id.id}: $e'); // Continue with null change24h rather than failing the entire price update } - + _pricesCache[asset.id.symbol.configSymbol] = CexPrice( ticker: asset.id.id, price: fiatPrice.toDouble(), @@ -622,24 +631,28 @@ class CoinsRepo { lastUpdated: DateTime.fromMillisecondsSinceEpoch( (pricesJson['last_updated_timestamp'] as int? ?? 0) * 1000, ), - priceProvider: - cexDataProvider(pricesJson['price_provider'] as String? ?? ''), + priceProvider: cexDataProvider( + pricesJson['price_provider'] as String? ?? '', + ), change24h: double.tryParse(pricesJson['change_24h'] as String? ?? ''), - changeProvider: - cexDataProvider(pricesJson['change_24h_provider'] as String? ?? ''), + changeProvider: cexDataProvider( + pricesJson['change_24h_provider'] as String? ?? '', + ), volume24h: double.tryParse(pricesJson['volume24h'] as String? ?? ''), - volumeProvider: - cexDataProvider(pricesJson['volume_provider'] as String? ?? ''), + volumeProvider: cexDataProvider( + pricesJson['volume_provider'] as String? ?? '', + ), ); }); return prices; } Future?> _updateFromFallback() async { - final List ids = (await _kdfSdk.assets.getActivatedAssets()) - .map((c) => c.id.symbol.coinGeckoId ?? '') - .toList() - ..removeWhere((id) => id.isEmpty); + final List ids = + (await _kdfSdk.assets.getActivatedAssets()) + .map((c) => c.id.symbol.coinGeckoId ?? '') + .toList() + ..removeWhere((id) => id.isEmpty); final Uri fallbackUri = Uri.parse( 'https://api.coingecko.com/api/v3/simple/price?ids=' '${ids.join(',')}&vs_currencies=usd', @@ -672,8 +685,9 @@ class CoinsRepo { // Coins with the same coingeckoId supposedly have same usd price // (e.g. KMD == KMD-BEP20) - final Iterable samePriceCoins = - getKnownCoins().where((coin) => coin.coingeckoId == coingeckoId); + final Iterable samePriceCoins = getKnownCoins().where( + (coin) => coin.coingeckoId == coingeckoId, + ); for (final Coin coin in samePriceCoins) { prices[coin.id.symbol.configSymbol] = CexPrice( @@ -695,8 +709,9 @@ class CoinsRepo { // the SDK's balance watchers to get live updates. We still // implement it for backward compatibility. final walletCoinsCopy = Map.from(walletCoins); - final coins = - walletCoinsCopy.values.where((coin) => coin.isActive).toList(); + final coins = walletCoinsCopy.values + .where((coin) => coin.isActive) + .toList(); // Get balances from the SDK for all active coins for (final coin in coins) { @@ -721,15 +736,15 @@ class CoinsRepo { // Only yield if there's a change if (balanceChanged || spendableChanged) { // Update the cache - _balancesCache[coin.id.id] = - (balance: newBalance, spendable: newSpendable); + _balancesCache[coin.id.id] = ( + balance: newBalance, + spendable: newSpendable, + ); // Yield updated coin with new balance // We still set both the deprecated fields and rely on the SDK // for future access to maintain backward compatibility - yield coin.copyWith( - sendableBalance: newSpendable, - ); + yield coin.copyWith(sendableBalance: newSpendable); } } catch (e, s) { _log.warning('Failed to update balance for ${coin.id}', e, s); @@ -737,8 +752,10 @@ class CoinsRepo { } } - @Deprecated('Use KomodoDefiSdk withdraw method instead. ' - 'This will be removed in the future.') + @Deprecated( + 'Use KomodoDefiSdk withdraw method instead. ' + 'This will be removed in the future.', + ) Future> withdraw( WithdrawRequest request, ) async { @@ -767,9 +784,7 @@ class CoinsRepo { response['result'] as Map? ?? {}, ); - return BlocResponse( - result: withdrawDetails, - ); + return BlocResponse(result: withdrawDetails); } /// Get a cached price for a given coin symbol diff --git a/lib/bloc/nft_image/nft_image_bloc.dart b/lib/bloc/nft_image/nft_image_bloc.dart new file mode 100644 index 0000000000..1dd3c35ebb --- /dev/null +++ b/lib/bloc/nft_image/nft_image_bloc.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart' show Equatable; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; + +part 'nft_image_event.dart'; +part 'nft_image_state.dart'; + +/// BLoC for managing NFT image loading with fallback mechanism +class NftImageBloc extends Bloc { + NftImageBloc({required IpfsGatewayManager ipfsGatewayManager}) + : _ipfsGatewayManager = ipfsGatewayManager, + super(const NftImageState()) { + on(_onImageLoadStarted); + on(_onImageLoadFailed); + on(_onImageLoadSucceeded); + on(_onImageRetryStarted); + on(_onImageCleared); + } + + final IpfsGatewayManager _ipfsGatewayManager; + + static const int maxRetryAttempts = 3; + static const Duration baseRetryDelay = Duration(seconds: 1); + + Timer? _retryTimer; + + /// Find the first working URL from the list + Future _findWorkingUrl(List urls, int startIndex) async { + return _ipfsGatewayManager.findWorkingUrl( + urls, + startIndex: startIndex, + onUrlTested: (url, success, errorMessage) { + if (!success) { + // Log failed attempts are handled by the gateway manager + // Additional logging can be done here if needed + } + }, + ); + } + + /// Detect media type from URL + static NftMediaType _detectMediaType(String url) { + final lowerUrl = url.toLowerCase(); + if (lowerUrl.endsWith('.svg')) return NftMediaType.svg; + if (lowerUrl.endsWith('.gif')) return NftMediaType.gif; + if (lowerUrl.endsWith('.mp4') || + lowerUrl.endsWith('.webm') || + lowerUrl.endsWith('.mov')) { + return NftMediaType.video; + } + return NftMediaType.image; + } + + /// Generates all possible URLs for the image including normalized URL and fallbacks + Future> _generateAllUrls(String imageUrl) async { + final List urls = []; + + // First, try to normalize the URL if it's an IPFS URL + final normalizedUrl = _ipfsGatewayManager.normalizeIpfsUrl(imageUrl); + if (normalizedUrl != null && normalizedUrl != imageUrl) { + urls.add(normalizedUrl); + } + + // Add the original URL if not already added + if (!urls.contains(imageUrl)) { + urls.add(imageUrl); + } + + // Generate IPFS gateway alternatives if it's an IPFS URL + if (IpfsGatewayManager.isIpfsUrl(imageUrl)) { + final ipfsUrls = await _ipfsGatewayManager.getReliableGatewayUrls( + imageUrl, + ); + // Add URLs that aren't already in the list + for (final url in ipfsUrls) { + if (!urls.contains(url)) { + urls.add(url); + } + } + } + + return urls; + } + + /// Handles the load image started event + Future _onImageLoadStarted( + NftImageLoadStarted event, + Emitter emit, + ) async { + _retryTimer?.cancel(); + + final allUrls = await _generateAllUrls(event.imageUrl); + final mediaType = _detectMediaType(event.imageUrl); + + if (allUrls.isEmpty) { + emit( + state.copyWith( + status: NftImageStatus.failure, + errorMessage: 'No URLs available to load', + mediaType: mediaType, + ), + ); + return; + } + + // Emit initial state with all URLs but no current URL yet + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: null, + currentUrlIndex: 0, + retryCount: 0, + allUrls: allUrls, + errorMessage: null, + isRetrying: false, + mediaType: mediaType, + ), + ); + + // Find the first working URL + final workingUrl = await _findWorkingUrl(allUrls, 0); + + if (workingUrl != null) { + final urlIndex = allUrls.indexOf(workingUrl); + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: workingUrl, + currentUrlIndex: urlIndex, + ), + ); + } else { + emit( + state.copyWith( + status: NftImageStatus.exhausted, + errorMessage: 'No accessible URLs found', + ), + ); + } + } + + /// Handles image load failure - try next URL immediately + Future _onImageLoadFailed( + NftImageLoadFailed event, + Emitter emit, + ) async { + // Log the failed attempt + _ipfsGatewayManager.logGatewayAttempt( + event.failedUrl, + false, + errorMessage: event.errorMessage, + ); + + // Try to find the next working URL + final nextWorkingUrl = await _findWorkingUrl( + state.allUrls, + state.currentUrlIndex + 1, + ); + + if (nextWorkingUrl != null && state.retryCount < maxRetryAttempts) { + final urlIndex = state.allUrls.indexOf(nextWorkingUrl); + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: nextWorkingUrl, + currentUrlIndex: urlIndex, + retryCount: state.retryCount + 1, + errorMessage: null, + isRetrying: false, + ), + ); + } else { + // All URLs exhausted or max retries reached + emit( + state.copyWith( + status: NftImageStatus.exhausted, + errorMessage: event.errorMessage ?? 'All image URLs failed to load', + isRetrying: false, + ), + ); + } + } + + /// Handles successful image load + Future _onImageLoadSucceeded( + NftImageLoadSucceeded event, + Emitter emit, + ) async { + // Return early if this URL is already successfully loaded + if (state.status == NftImageStatus.success && + state.currentUrl == event.loadedUrl) { + return; + } + + _retryTimer?.cancel(); + + // Log the successful attempt + _ipfsGatewayManager.logGatewayAttempt( + event.loadedUrl, + true, + loadTime: event.loadTime, + ); + + emit( + state.copyWith( + status: NftImageStatus.success, + currentUrl: event.loadedUrl, + errorMessage: null, + isRetrying: false, + ), + ); + } + + /// Handles manual retry started event (only used for failed states) + Future _onImageRetryStarted( + NftImageRetryStarted event, + Emitter emit, + ) async { + if (state.status == NftImageStatus.exhausted || + state.status == NftImageStatus.failure) { + // Try to find any working URL from the beginning + final workingUrl = await _findWorkingUrl(state.allUrls, 0); + + if (workingUrl != null) { + final urlIndex = state.allUrls.indexOf(workingUrl); + emit( + state.copyWith( + status: NftImageStatus.loading, + currentUrl: workingUrl, + currentUrlIndex: urlIndex, + retryCount: 0, + errorMessage: null, + isRetrying: false, + ), + ); + } + } + } + + /// Handles clear event + Future _onImageCleared( + NftImageCleared event, + Emitter emit, + ) async { + _retryTimer?.cancel(); + emit(const NftImageState()); + } + + @override + Future close() { + _retryTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/bloc/nft_image/nft_image_event.dart b/lib/bloc/nft_image/nft_image_event.dart new file mode 100644 index 0000000000..9b557cfaa7 --- /dev/null +++ b/lib/bloc/nft_image/nft_image_event.dart @@ -0,0 +1,54 @@ +part of 'nft_image_bloc.dart'; + +/// Events for NFT image loading with fallback mechanism +abstract class NftImageEvent extends Equatable { + const NftImageEvent(); +} + +/// Event to start loading an image (bloc will generate fallback URLs) +class NftImageLoadStarted extends NftImageEvent { + const NftImageLoadStarted({required this.imageUrl}); + + final String imageUrl; + + @override + List get props => [imageUrl]; +} + +/// Event triggered when an image fails to load +class NftImageLoadFailed extends NftImageEvent { + const NftImageLoadFailed({required this.failedUrl, this.errorMessage}); + + final String failedUrl; + final String? errorMessage; + + @override + List get props => [failedUrl, errorMessage]; +} + +/// Event triggered when an image loads successfully +class NftImageLoadSucceeded extends NftImageEvent { + const NftImageLoadSucceeded({required this.loadedUrl, this.loadTime}); + + final String loadedUrl; + final Duration? loadTime; + + @override + List get props => [loadedUrl, loadTime]; +} + +/// Event to start retrying with the next URL in the fallback list +class NftImageRetryStarted extends NftImageEvent { + const NftImageRetryStarted(); + + @override + List get props => []; +} + +/// Event when the image loading state has been cleared +class NftImageCleared extends NftImageEvent { + const NftImageCleared(); + + @override + List get props => []; +} diff --git a/lib/bloc/nft_image/nft_image_state.dart b/lib/bloc/nft_image/nft_image_state.dart new file mode 100644 index 0000000000..e1bdca385d --- /dev/null +++ b/lib/bloc/nft_image/nft_image_state.dart @@ -0,0 +1,88 @@ +part of 'nft_image_bloc.dart'; + +/// Image loading states for NFT image fallback mechanism +enum NftImageStatus { initial, loading, success, retrying, exhausted, failure } + +/// NFT media types for display handling +enum NftMediaType { image, video, svg, gif, unknown } + +/// State for NFT image loading with fallback mechanism +class NftImageState extends Equatable { + const NftImageState({ + this.status = NftImageStatus.initial, + this.currentUrl, + this.currentUrlIndex = 0, + this.retryCount = 0, + this.allUrls = const [], + this.errorMessage, + this.isRetrying = false, + this.mediaType = NftMediaType.unknown, + }); + + final NftImageStatus status; + final String? currentUrl; + final int currentUrlIndex; + final int retryCount; + final List allUrls; + final String? errorMessage; + final bool isRetrying; + final NftMediaType mediaType; + + /// Whether there are more URLs to try + bool get hasMoreUrls => currentUrlIndex < allUrls.length - 1; + + /// Whether all URLs have been exhausted + bool get isExhausted => + currentUrlIndex >= allUrls.length - 1 && status == NftImageStatus.failure; + + /// The next URL to try + String? get nextUrl { + if (!hasMoreUrls) return null; + return allUrls[currentUrlIndex + 1]; + } + + /// Whether the widget should show a placeholder + bool get shouldShowPlaceholder => + status == NftImageStatus.exhausted || + (status == NftImageStatus.failure && !hasMoreUrls); + + /// Whether the widget is in a loading state + bool get isLoading => + status == NftImageStatus.loading || + status == NftImageStatus.retrying || + currentUrl == null; + + NftImageState copyWith({ + NftImageStatus? status, + String? currentUrl, + int? currentUrlIndex, + int? retryCount, + List? allUrls, + String? errorMessage, + bool? isRetrying, + NftMediaType? mediaType, + }) { + return NftImageState( + status: status ?? this.status, + currentUrl: currentUrl ?? this.currentUrl, + currentUrlIndex: currentUrlIndex ?? this.currentUrlIndex, + retryCount: retryCount ?? this.retryCount, + allUrls: allUrls ?? this.allUrls, + errorMessage: errorMessage, + isRetrying: isRetrying ?? this.isRetrying, + mediaType: mediaType ?? this.mediaType, + ); + } + + @override + List get props => [ + status, + currentUrl, + currentUrlIndex, + retryCount, + allUrls, + errorMessage, + isRetrying, + mediaType, + ]; +} diff --git a/lib/main.dart b/lib/main.dart index 446f92304c..3c7e1e6e81 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,52 +44,49 @@ PerformanceMode? get appDemoPerformanceMode => _appDemoPerformanceMode ?? _getPerformanceModeFromUrl(); Future main() async { - await runZonedGuarded( - () async { - usePathUrlStrategy(); - WidgetsFlutterBinding.ensureInitialized(); - Bloc.observer = AppBlocObserver(); - PerformanceAnalytics.init(); - - FlutterError.onError = (FlutterErrorDetails details) { - catchUnhandledExceptions(details.exception, details.stack); - }; - - // Foundational dependencies / setup - everything else builds on these 3. - // The current focus is migrating mm2Api to the new sdk, so that the sdk - // is the only/primary API/repository for KDF - final KomodoDefiSdk komodoDefiSdk = await mm2.initialize(); - final mm2Api = Mm2Api(mm2: mm2, sdk: komodoDefiSdk); - await AppBootstrapper.instance.ensureInitialized(komodoDefiSdk, mm2Api); - - final coinsRepo = CoinsRepo(kdfSdk: komodoDefiSdk, mm2: mm2); - final walletsRepository = WalletsRepository( - komodoDefiSdk, - mm2Api, - getStorage(), - ); - - runApp( - EasyLocalization( - supportedLocales: localeList, - fallbackLocale: localeList.first, - useFallbackTranslations: true, - useOnlyLangCode: true, - path: '$assetsPath/translations', - child: MultiRepositoryProvider( - providers: [ - RepositoryProvider(create: (_) => komodoDefiSdk), - RepositoryProvider(create: (_) => mm2Api), - RepositoryProvider(create: (_) => coinsRepo), - RepositoryProvider(create: (_) => walletsRepository), - ], - child: const MyApp(), - ), + await runZonedGuarded(() async { + usePathUrlStrategy(); + WidgetsFlutterBinding.ensureInitialized(); + Bloc.observer = AppBlocObserver(); + PerformanceAnalytics.init(); + + FlutterError.onError = (FlutterErrorDetails details) { + catchUnhandledExceptions(details.exception, details.stack); + }; + + // Foundational dependencies / setup - everything else builds on these 3. + // The current focus is migrating mm2Api to the new sdk, so that the sdk + // is the only/primary API/repository for KDF + final KomodoDefiSdk komodoDefiSdk = await mm2.initialize(); + final mm2Api = Mm2Api(mm2: mm2, sdk: komodoDefiSdk); + await AppBootstrapper.instance.ensureInitialized(komodoDefiSdk, mm2Api); + + final coinsRepo = CoinsRepo(kdfSdk: komodoDefiSdk, mm2: mm2); + final walletsRepository = WalletsRepository( + komodoDefiSdk, + mm2Api, + getStorage(), + ); + + runApp( + EasyLocalization( + supportedLocales: localeList, + fallbackLocale: localeList.first, + useFallbackTranslations: true, + useOnlyLangCode: true, + path: '$assetsPath/translations', + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider(create: (_) => komodoDefiSdk), + RepositoryProvider(create: (_) => mm2Api), + RepositoryProvider(create: (_) => coinsRepo), + RepositoryProvider(create: (_) => walletsRepository), + ], + child: const MyApp(), ), - ); - }, - catchUnhandledExceptions, - ); + ), + ); + }, catchUnhandledExceptions); } void catchUnhandledExceptions(Object error, StackTrace? stack) { @@ -145,7 +142,10 @@ class MyApp extends StatelessWidget { BlocProvider( create: (_) { final bloc = AuthBloc( - komodoDefiSdk, walletsRepository, SettingsRepository()); + komodoDefiSdk, + walletsRepository, + SettingsRepository(), + ); bloc.add(const AuthLifecycleCheckRequested()); return bloc; }, @@ -177,10 +177,6 @@ FeedbackThemeData _feedbackThemeData(ThemeData appTheme) { colorScheme: appTheme.colorScheme, sheetIsDraggable: true, feedbackSheetHeight: 0.3, - drawColors: [ - Colors.red, - Colors.white, - Colors.green, - ], + drawColors: [Colors.red, Colors.white, Colors.green], ); } diff --git a/lib/model/nft.dart b/lib/model/nft.dart index eb8fe8ab08..5d06236f9b 100644 --- a/lib/model/nft.dart +++ b/lib/model/nft.dart @@ -75,16 +75,7 @@ class NftToken { String? get description => metaData?.description ?? uriMeta.description; String? get imageUrl { final image = uriMeta.imageUrl ?? metaData?.image ?? uriMeta.animationUrl; - if (image == null) return null; - - // Image.network does not support ipfs protocol - String url = image.replaceFirst('ipfs://', 'https://ipfs.io/ipfs/'); - - // Also standardize gateway URLs to use ipfs.io Match both patterns: - // gateway.moralisipfs.com/ipfs/ and common.ipfs.gateway/ipfs/ - final gatewayPattern = - RegExp(r'https://[^/]+(?:\.ipfs\.|ipfs\.)[^/]+/ipfs/'); - return url.replaceAllMapped(gatewayPattern, (_) => 'https://ipfs.io/ipfs/'); + return image; // Return raw URL - bloc will handle normalization and fallbacks } String get uuid => '$chain:$tokenAddress:$tokenId'.hashCode.toString(); diff --git a/lib/shared/constants/ipfs_constants.dart b/lib/shared/constants/ipfs_constants.dart new file mode 100644 index 0000000000..f890f428c3 --- /dev/null +++ b/lib/shared/constants/ipfs_constants.dart @@ -0,0 +1,28 @@ +/// IPFS gateway configuration constants +class IpfsConstants { + IpfsConstants._(); + + /// Primary gateways ordered by reliability and performance for web platforms + /// These gateways are optimized for CORS support and reduced Cloudflare issues + static const List defaultWebOptimizedGateways = [ + 'https://dweb.link/ipfs/', // IPFS Foundation - good CORS, subdomain resolution + 'https://gateway.pinata.cloud/ipfs/', // Pinata - reliable, NFT-focused + 'https://cloudflare-ipfs.com/ipfs/', // Cloudflare - fast CDN + 'https://nftstorage.link/ipfs/', // NFT Storage - specialized for NFTs + 'https://ipfs.io/ipfs/', // Standard IPFS Foundation gateway - fallback + ]; + + /// Standard gateways for non-web platforms (mobile, desktop) + /// These gateways provide good reliability across different platforms + static const List defaultStandardGateways = [ + 'https://ipfs.io/ipfs/', + 'https://dweb.link/ipfs/', + 'https://gateway.pinata.cloud/ipfs/', + ]; + + /// Circuit breaker cooldown duration for failed gateways + static const Duration failureCooldown = Duration(minutes: 5); + + /// IPFS protocol identifier + static const String ipfsProtocol = 'ipfs://'; +} diff --git a/lib/shared/utils/ipfs_gateway_manager.dart b/lib/shared/utils/ipfs_gateway_manager.dart new file mode 100644 index 0000000000..3785b0f1fb --- /dev/null +++ b/lib/shared/utils/ipfs_gateway_manager.dart @@ -0,0 +1,250 @@ +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:mutex/mutex.dart'; +import 'package:web_dex/shared/constants/ipfs_constants.dart'; + +/// Manages IPFS gateway selection and fallback mechanisms for reliable content loading +class IpfsGatewayManager { + /// Creates an IPFS gateway manager with optional custom gateway configurations + /// + /// [webOptimizedGateways] - List of gateways optimized for web platforms + /// [standardGateways] - List of gateways for non-web platforms + /// [failureCooldown] - Duration to wait before retrying a failed gateway + /// [httpClient] - HTTP client for testing URL accessibility (optional, defaults to http.Client()) + /// [urlTestTimeout] - Timeout duration for URL accessibility tests + IpfsGatewayManager({ + List? webOptimizedGateways, + List? standardGateways, + Duration? failureCooldown, + http.Client? httpClient, + Duration? urlTestTimeout, + }) : _webOptimizedGateways = + webOptimizedGateways ?? IpfsConstants.defaultWebOptimizedGateways, + _standardGateways = + standardGateways ?? IpfsConstants.defaultStandardGateways, + _failureCooldown = failureCooldown ?? IpfsConstants.failureCooldown, + _httpClient = httpClient ?? http.Client(), + _urlTestTimeout = urlTestTimeout ?? const Duration(seconds: 5); + + // Configuration + final List _webOptimizedGateways; + final List _standardGateways; + final Duration _failureCooldown; + final http.Client _httpClient; + final Duration _urlTestTimeout; + + // Failed URL tracking for circuit breaker pattern - protected by mutex for thread safety + final Set _failedUrls = {}; + final Map _failureTimestamps = {}; + final ReadWriteMutex _collectionsMutex = ReadWriteMutex(); + + // Gateway patterns to normalize to our preferred gateways + static final RegExp _gatewayPattern = RegExp( + r'https://([^/]+(?:\.ipfs\.|ipfs\.)[^/]+)/ipfs/', + caseSensitive: false, + ); + + // Subdomain IPFS pattern (e.g., https://QmXYZ.ipfs.dweb.link) + static final RegExp _subdomainPattern = RegExp( + r'https://([a-zA-Z0-9]+)\.ipfs\.([^/]+)', + caseSensitive: false, + ); + + /// Returns the appropriate list of gateways based on the current platform + List get gateways { + if (kIsWeb) { + return _webOptimizedGateways; + } + return _standardGateways; + } + + /// Converts an IPFS URL to HTTP gateway URLs with multiple fallback options + List getGatewayUrls(String? url) { + if (url == null || url.isEmpty) return []; + + final cid = _extractContentId(url); + if (cid == null) return [url]; // Not an IPFS URL, return as-is + + // Generate URLs for all available gateways + return gateways.map((gateway) => '$gateway$cid').toList(); + } + + /// Gets the primary (preferred) gateway URL for an IPFS link + String? getPrimaryGatewayUrl(String? url) { + final urls = getGatewayUrls(url); + return urls.isNotEmpty ? urls.first : null; + } + + /// Extracts the IPFS content ID from various URL formats + static String? _extractContentId(String url) { + // Handle ipfs:// protocol (case-insensitive) + if (url.toLowerCase().startsWith( + IpfsConstants.ipfsProtocol.toLowerCase(), + )) { + return url.substring(IpfsConstants.ipfsProtocol.length); + } + + // Handle gateway format (e.g., https://gateway.com/ipfs/QmXYZ) + // handle gateway first, since subdomain format will also match + // this pattern + final gatewayMatch = _gatewayPattern.firstMatch(url); + if (gatewayMatch != null) { + return url.substring(gatewayMatch.end); + } + + // Handle subdomain format (e.g., https://QmXYZ.ipfs.dweb.link/path) + final subdomainMatch = _subdomainPattern.firstMatch(url); + if (subdomainMatch != null) { + final cid = subdomainMatch.group(1)!; + final remainingPath = url.substring(subdomainMatch.end); + return remainingPath.isEmpty ? cid : '$cid$remainingPath'; + } + + // Check if URL contains /ipfs/ somewhere (case-insensitive) + final ipfsIndex = url.toLowerCase().indexOf('/ipfs/'); + if (ipfsIndex != -1) { + return url.substring(ipfsIndex + 6); // +6 for '/ipfs/'.length + } + + return null; // Not a recognized IPFS URL + } + + /// Normalizes an IPFS URL to use the preferred gateway + String? normalizeIpfsUrl(String? url) { + return getPrimaryGatewayUrl(url); + } + + /// Checks if a URL is an IPFS URL (any format) + static bool isIpfsUrl(String? url) { + if (url == null || url.isEmpty) return false; + + return url.toLowerCase().startsWith( + IpfsConstants.ipfsProtocol.toLowerCase(), + ) || + _subdomainPattern.hasMatch(url) || + _gatewayPattern.hasMatch(url) || + url.toLowerCase().contains('/ipfs/'); + } + + /// Logs gateway performance for debugging + Future logGatewayAttempt( + String gatewayUrl, + bool success, { + String? errorMessage, + Duration? loadTime, + }) async { + await _collectionsMutex.protectWrite(() async { + if (success) { + // Remove from failed set on success + _failedUrls.remove(gatewayUrl); + _failureTimestamps.remove(gatewayUrl); + } else { + // Mark as failed + _failedUrls.add(gatewayUrl); + _failureTimestamps[gatewayUrl] = DateTime.now(); + } + }); + + if (kDebugMode) { + final status = success ? 'SUCCESS' : 'FAILED'; + final timing = loadTime != null ? ' (${loadTime.inMilliseconds}ms)' : ''; + final error = errorMessage != null ? ' - $errorMessage' : ''; + + debugPrint('IPFS Gateway $status: $gatewayUrl$timing$error'); + } + } + + /// Checks if a URL should be skipped due to recent failures + Future shouldSkipUrl(String url) async { + return await _collectionsMutex.protectWrite(() async { + if (!_failedUrls.contains(url)) return false; + + final failureTime = _failureTimestamps[url]; + if (failureTime == null) return false; + + final now = DateTime.now(); + if (now.difference(failureTime) > _failureCooldown) { + // Cooldown expired, remove from failed set + _failedUrls.remove(url); + _failureTimestamps.remove(url); + return false; + } + + return true; + }); + } + + /// Gets gateway URLs excluding recently failed ones + Future> getReliableGatewayUrls(String? url) async { + final allUrls = getGatewayUrls(url); + final reliableUrls = []; + + for (final urlToCheck in allUrls) { + final shouldSkip = await shouldSkipUrl(urlToCheck); + if (!shouldSkip) { + reliableUrls.add(urlToCheck); + } + } + + return reliableUrls; + } + + /// Test if a URL is accessible by making a HEAD request + Future testUrlAccessibility(String url) async { + try { + final response = await _httpClient + .head(Uri.parse(url)) + .timeout(_urlTestTimeout); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + + /// Find the first working URL from a list of URLs + /// + /// [urls] - List of URLs to test + /// [startIndex] - Index to start testing from (defaults to 0) + /// [onUrlTested] - Optional callback called for each URL test result + Future findWorkingUrl( + List urls, { + int startIndex = 0, + void Function(String url, bool success, String? errorMessage)? onUrlTested, + }) async { + for (int i = startIndex; i < urls.length; i++) { + final url = urls[i]; + + // Skip URLs that are recently failed according to circuit breaker + final shouldSkip = await shouldSkipUrl(url); + if (shouldSkip) { + continue; + } + + final isWorking = await testUrlAccessibility(url); + + // Call the callback if provided + onUrlTested?.call( + url, + isWorking, + isWorking ? null : 'URL accessibility test failed', + ); + + if (isWorking) { + return url; + } else { + // Log the failed attempt + await logGatewayAttempt( + url, + false, + errorMessage: 'URL accessibility test failed', + ); + } + } + return null; + } + + /// Dispose of resources + void dispose() { + _httpClient.close(); + } +} diff --git a/lib/views/nfts/common/widgets/nft_image.dart b/lib/views/nfts/common/widgets/nft_image.dart index 7227107f4f..1fb9cc172d 100644 --- a/lib/views/nfts/common/widgets/nft_image.dart +++ b/lib/views/nfts/common/widgets/nft_image.dart @@ -1,38 +1,55 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; import 'package:video_player/video_player.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; +import 'package:web_dex/bloc/nft_image/nft_image_bloc.dart'; enum NftImageType { image, video, placeholder } class NftImage extends StatelessWidget { - const NftImage({ - super.key, - this.imagePath, - }); + const NftImage({super.key, this.imageUrl}); - final String? imagePath; + final String? imageUrl; @override Widget build(BuildContext context) { - switch (type) { - case NftImageType.image: - return _NftImage(imageUrl: imagePath!); - case NftImageType.video: - // According to [video_player](https://pub.dev/packages/video_player) - // it works only on Android, iOS, Web - // Waiting for a future updates - return PlatformTuner.isNativeDesktop - ? const _NftPlaceholder() - : _NftVideo(videoUrl: imagePath!); - case NftImageType.placeholder: - return const _NftPlaceholder(); - } + return BlocProvider( + create: (context) => + NftImageBloc(ipfsGatewayManager: context.read()), + child: Builder( + builder: (context) { + switch (type) { + case NftImageType.image: + return _NftImageWithFallback( + key: ValueKey(imageUrl!), + imageUrl: imageUrl!, + ); + case NftImageType.video: + // According to [video_player](https://pub.dev/packages/video_player) + // it works only on Android, iOS, Web + // Waiting for a future updates + return PlatformTuner.isNativeDesktop + ? const _NftPlaceholder() + : _NftVideoWithFallback( + key: ValueKey(imageUrl!), + videoUrl: imageUrl!, + ); + case NftImageType.placeholder: + return const _NftPlaceholder(); + } + }, + ), + ); } NftImageType get type { - if (imagePath != null) { - if (imagePath!.endsWith('.mp4')) { + if (imageUrl != null) { + final path = imageUrl!.toLowerCase(); + if (path.endsWith('.mp4') || + path.endsWith('.webm') || + path.endsWith('.mov')) { return NftImageType.video; } else { return NftImageType.image; @@ -42,77 +59,234 @@ class NftImage extends StatelessWidget { } } -class _NftImage extends StatelessWidget { - const _NftImage({required this.imageUrl}); +class _NftImageWithFallback extends StatefulWidget { + const _NftImageWithFallback({required this.imageUrl, super.key}); + final String imageUrl; + @override + State<_NftImageWithFallback> createState() => _NftImageWithFallbackState(); +} + +class _NftImageWithFallbackState extends State<_NftImageWithFallback> { + @override + void initState() { + super.initState(); + // Request the bloc to start loading and finding a working URL + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add( + NftImageLoadStarted(imageUrl: widget.imageUrl), + ); + }); + } + + @override + void didUpdateWidget(covariant _NftImageWithFallback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl) { + final bloc = context.read(); + bloc.add(const NftImageCleared()); + bloc.add(NftImageLoadStarted(imageUrl: widget.imageUrl)); + } + } + @override Widget build(BuildContext context) { - final isSvg = imageUrl.endsWith('.svg'); - final isGif = imageUrl.endsWith('.gif'); - - return ClipRRect( - borderRadius: BorderRadius.circular(24), - child: isSvg - ? SvgPicture.network(imageUrl, fit: BoxFit.cover) - : Image.network( - imageUrl, - filterQuality: FilterQuality.high, - fit: BoxFit.cover, - gaplessPlayback: isGif, // Ensures smoother GIF animation - errorBuilder: (_, error, stackTrace) { - debugPrint('Error loading image: $error'); - debugPrintStack(stackTrace: stackTrace); - return const _NftPlaceholder(); - }, - ), + return BlocBuilder( + builder: (context, state) { + // Show placeholder for exhausted or error states + if (state.shouldShowPlaceholder) { + return const _NftPlaceholder(); + } + + // Show loading indicator if no URL is ready yet + if (state.isLoading && state.currentUrl == null) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + + // Don't render anything if we don't have a current URL + if (state.currentUrl == null) { + return const _NftPlaceholder(); + } + + final currentUrl = state.currentUrl!; + + return ClipRRect( + borderRadius: BorderRadius.circular(24), + child: _buildImageWidget(context, state, currentUrl), + ); + }, ); } + + Widget _buildImageWidget( + BuildContext context, + NftImageState state, + String currentUrl, + ) { + switch (state.mediaType) { + case NftMediaType.svg: + return SvgPicture.network( + currentUrl, + fit: BoxFit.cover, + placeholderBuilder: (_) => + const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + case NftMediaType.gif: + case NftMediaType.image: + default: + return Image.network( + currentUrl, + filterQuality: FilterQuality.high, + fit: BoxFit.cover, + gaplessPlayback: state.mediaType == NftMediaType.gif, + loadingBuilder: (context, child, loadingProgress) { + // If frame is available, image is successfully loaded + if (loadingProgress == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().add( + NftImageLoadSucceeded(loadedUrl: currentUrl), + ); + } + }); + + return child; + } + + // Show loading indicator while image is loading + return const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ); + }, + errorBuilder: (context, error, stackTrace) { + // Handle image load error - notify bloc to try next URL + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().add( + NftImageLoadFailed( + failedUrl: currentUrl, + errorMessage: error.toString(), + ), + ); + } + }); + + // Show loading indicator while bloc processes the failure + return const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ); + }, + ); + } + } } -class _NftVideo extends StatefulWidget { - const _NftVideo({required this.videoUrl}); +class _NftVideoWithFallback extends StatefulWidget { + const _NftVideoWithFallback({required this.videoUrl, super.key}); final String videoUrl; @override - State<_NftVideo> createState() => _NftVideoState(); + State<_NftVideoWithFallback> createState() => _NftVideoWithFallbackState(); } -class _NftVideoState extends State<_NftVideo> { - late final VideoPlayerController _controller; +class _NftVideoWithFallbackState extends State<_NftVideoWithFallback> { + VideoPlayerController? _controller; + String? currentVideoUrl; @override void initState() { - _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + super.initState(); + // Don't initialize controller with empty URI - wait for valid URL - _controller.initialize().then((_) { - _controller.setLooping(true); - _controller.play(); - setState(() {}); + // Request the bloc to start loading and finding a working URL + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add( + NftImageLoadStarted(imageUrl: widget.videoUrl), + ); }); - - super.initState(); } @override - void dispose() { - _controller.dispose(); - super.dispose(); + void didUpdateWidget(covariant _NftVideoWithFallback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.videoUrl != widget.videoUrl) { + _controller?.dispose(); + _controller = null; + currentVideoUrl = null; + final bloc = context.read(); + bloc.add(const NftImageCleared()); + bloc.add(NftImageLoadStarted(imageUrl: widget.videoUrl)); + } } @override Widget build(BuildContext context) { - return _controller.value.isInitialized - ? ClipRRect( - borderRadius: BorderRadius.circular(24), - child: VideoPlayer(_controller), - ) - : const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ); + return BlocConsumer( + listener: (context, state) { + // Handle URL changes from the bloc + if (state.currentUrl != null && state.currentUrl != currentVideoUrl) { + _initializeVideoController(state.currentUrl!); + } + }, + builder: (context, state) { + if (state.shouldShowPlaceholder) { + return const _NftPlaceholder(); + } + + if (currentVideoUrl == null || state.isLoading || _controller == null) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + + return _controller!.value.isInitialized + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: VideoPlayer(_controller!), + ) + : const Center(child: CircularProgressIndicator(strokeWidth: 2)); + }, + ); + } + + void _initializeVideoController(String videoUrl) { + _controller?.dispose(); + currentVideoUrl = videoUrl; + + _controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); + + _controller! + .initialize() + .then((_) { + if (mounted) { + setState(() {}); + _controller!.setLooping(true); + _controller!.play(); + + // Notify bloc of successful load + context.read().add( + NftImageLoadSucceeded(loadedUrl: videoUrl), + ); + } + }) + .catchError((error) { + debugPrint('Error initializing video from $videoUrl: $error'); + if (mounted) { + // Notify bloc of failed load + context.read().add( + NftImageLoadFailed( + failedUrl: videoUrl, + errorMessage: error.toString(), + ), + ); + } + }); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); } } @@ -122,12 +296,8 @@ class _NftPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - child: const Center( - child: Icon(Icons.monetization_on, size: 36), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: const Center(child: Icon(Icons.monetization_on, size: 36)), ); } } diff --git a/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart b/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart index 2663b665ae..6d8f1ae84d 100644 --- a/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart +++ b/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart @@ -31,39 +31,41 @@ class NftDetailsPageDesktop extends StatelessWidget { children: [ Flexible( child: ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 389, maxHeight: 440), - child: NftImage(imagePath: nft.imageUrl), + constraints: const BoxConstraints( + maxWidth: 389, + maxHeight: 440, + ), + child: NftImage(imageUrl: nft.imageUrl), ), ), const SizedBox(width: 32), Flexible( child: ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 416, maxHeight: 440), + constraints: const BoxConstraints( + maxWidth: 416, + maxHeight: 440, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.max, children: [ - NftDescription( - nft: nft, - isDescriptionShown: !isSend, - ), + NftDescription(nft: nft, isDescriptionShown: !isSend), const SizedBox(height: 12), if (state is! NftWithdrawSuccessState) NftData(nft: nft), if (isSend) - Flexible( - child: NftWithdrawView(nft: nft), - ) + Flexible(child: NftWithdrawView(nft: nft)) else ...[ const Spacer(), UiPrimaryButton( - text: LocaleKeys.send.tr(), - height: 40, - onPressed: () { - routingState.nftsState - .setDetailsAction(nft.uuid, true); - }), + text: LocaleKeys.send.tr(), + height: 40, + onPressed: () { + routingState.nftsState.setDetailsAction( + nft.uuid, + true, + ); + }, + ), ], ], ), diff --git a/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart b/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart index 64b8ca9dfe..4029a734fb 100644 --- a/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart +++ b/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart @@ -35,22 +35,16 @@ class _NftDetailsPageMobileState extends State { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (BuildContext context, NftWithdrawState state) { - final nft = state.nft; + builder: (BuildContext context, NftWithdrawState state) { + final nft = state.nft; - return SingleChildScrollView( - child: _isSend - ? _Send( - nft: nft, - close: _closeSend, - ) - : _Details( - nft: nft, - onBack: _livePage, - onSend: _showSend, - ), - ); - }); + return SingleChildScrollView( + child: _isSend + ? _Send(nft: nft, close: _closeSend) + : _Details(nft: nft, onBack: _livePage, onSend: _showSend), + ); + }, + ); } void _showSend() { @@ -91,16 +85,11 @@ class _Details extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 50), - PageHeader( - title: nft.name, - onBackButtonPressed: onBack, - ), + PageHeader(title: nft.name, onBackButtonPressed: onBack), const SizedBox(height: 5), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 343), - child: NftImage( - imagePath: nft.imageUrl, - ), + child: NftImage(imageUrl: nft.imageUrl), ), const SizedBox(height: 28), UiPrimaryButton( @@ -109,17 +98,14 @@ class _Details extends StatelessWidget { onPressed: onSend, ), const SizedBox(height: 28), - NftDescription(nft: nft) + NftDescription(nft: nft), ], ); } } class _Send extends StatelessWidget { - const _Send({ - required this.nft, - required this.close, - }); + const _Send({required this.nft, required this.close}); final NftToken nft; final VoidCallback close; @@ -134,9 +120,7 @@ class _Send extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 50), - NftDetailsHeaderMobile( - close: close, - ), + NftDetailsHeaderMobile(close: close), const SizedBox(height: 10), if (state is! NftWithdrawSuccessState) Padding( @@ -153,7 +137,7 @@ class _Send extends StatelessWidget { maxWidth: 40, maxHeight: 40, ), - child: NftImage(imagePath: nft.imageUrl), + child: NftImage(imageUrl: nft.imageUrl), ), const SizedBox(width: 8), Flexible( @@ -165,14 +149,18 @@ class _Send extends StatelessWidget { Text( nft.name, style: textTheme.bodySBold.copyWith( - color: colorScheme.primary, height: 1), + color: colorScheme.primary, + height: 1, + ), ), const SizedBox(height: 10), Text( nft.collectionName ?? '', - style: textTheme.bodyXS - .copyWith(color: colorScheme.s70, height: 1), - ) + style: textTheme.bodyXS.copyWith( + color: colorScheme.s70, + height: 1, + ), + ), ], ), ), diff --git a/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart b/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart index 41fd138588..84053b456e 100644 --- a/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart +++ b/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart @@ -39,99 +39,102 @@ class _NftWithdrawSuccessState extends State { borderRadius: BorderRadius.circular(20.0), color: colorScheme.surfContLow, ), - child: Column(children: [ - SvgPicture.asset( - '$assetsPath/ui_icons/success.svg', - colorFilter: ColorFilter.mode( - colorScheme.primary, - BlendMode.srcIn, + child: Column( + children: [ + SvgPicture.asset( + '$assetsPath/ui_icons/success.svg', + colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn), + height: 64, + width: 64, ), - height: 64, - width: 64, - ), - const SizedBox(height: 12), - Text( - LocaleKeys.successfullySent.tr(), - style: textTheme.heading2.copyWith(color: colorScheme.primary), - ), - const SizedBox(height: 20), - if (isMobile) - Container( - padding: const EdgeInsets.symmetric(vertical: 11), - decoration: BoxDecoration( + const SizedBox(height: 12), + Text( + LocaleKeys.successfullySent.tr(), + style: textTheme.heading2.copyWith(color: colorScheme.primary), + ), + const SizedBox(height: 20), + if (isMobile) + Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( border: Border( - top: BorderSide(color: colorScheme.surfContHigh), - bottom: BorderSide(color: colorScheme.surfContHigh))), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 40, - maxHeight: 40, + top: BorderSide(color: colorScheme.surfContHigh), + bottom: BorderSide(color: colorScheme.surfContHigh), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 40, + maxHeight: 40, + ), + child: NftImage(imageUrl: nft.imageUrl), ), - child: NftImage(imagePath: nft.imageUrl), - ), - const SizedBox(width: 8), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - nft.name, - style: textTheme.bodySBold.copyWith( - color: colorScheme.primary, - height: 1, + const SizedBox(width: 8), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + nft.name, + style: textTheme.bodySBold.copyWith( + color: colorScheme.primary, + height: 1, + ), ), - ), - const SizedBox(height: 8), - Text( - nft.collectionName ?? '', - style: textTheme.bodyXS.copyWith( - color: colorScheme.s70, - height: 1, + const SizedBox(height: 8), + Text( + nft.collectionName ?? '', + style: textTheme.bodyXS.copyWith( + color: colorScheme.s70, + height: 1, + ), ), - ) - ], - ), - ], - ), - ], + ], + ), + ], + ), + ], + ), ), - ), - SizedBox(height: isMobile ? 38 : 4), - NftDataRow( - title: LocaleKeys.date.tr(), - value: DateFormat('dd MMM yyyy HH:mm').format( + SizedBox(height: isMobile ? 38 : 4), + NftDataRow( + title: LocaleKeys.date.tr(), + value: DateFormat('dd MMM yyyy HH:mm').format( DateTime.fromMillisecondsSinceEpoch( - widget.state.timestamp * 1000)), - ), - const SizedBox(height: 24), - NftDataRow( - title: LocaleKeys.transactionId.tr(), - valueWidget: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), - child: HashExplorerLink( - hash: widget.state.txHash, - type: HashExplorerType.tx, - coin: widget.state.nft.parentCoin, + widget.state.timestamp * 1000, + ), ), ), - ), - const SizedBox(height: 24), - NftDataRow( - title: LocaleKeys.to.tr(), - valueWidget: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 165), - child: HashExplorerLink( - hash: widget.state.to, - type: HashExplorerType.address, - coin: widget.state.nft.parentCoin, + const SizedBox(height: 24), + NftDataRow( + title: LocaleKeys.transactionId.tr(), + valueWidget: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: HashExplorerLink( + hash: widget.state.txHash, + type: HashExplorerType.tx, + coin: widget.state.nft.parentCoin, + ), ), ), - ), - ]), + const SizedBox(height: 24), + NftDataRow( + title: LocaleKeys.to.tr(), + valueWidget: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 165), + child: HashExplorerLink( + hash: widget.state.to, + type: HashExplorerType.address, + coin: widget.state.nft.parentCoin, + ), + ), + ), + ], + ), ); } } diff --git a/lib/views/nfts/nft_list/nft_list_item.dart b/lib/views/nfts/nft_list/nft_list_item.dart index 2155b16dd0..e67b082b9c 100644 --- a/lib/views/nfts/nft_list/nft_list_item.dart +++ b/lib/views/nfts/nft_list/nft_list_item.dart @@ -62,17 +62,14 @@ class _NftListItemState extends State { child: InkWell( onTap: _onTap, child: GridTile( - footer: _NftData( - nft: widget.nft, - onSendTap: _onSendTap, - ), + footer: _NftData(nft: widget.nft, onSendTap: _onSendTap), child: Stack( children: [ Positioned.fill( child: AnimatedScale( duration: const Duration(milliseconds: 200), scale: isHover ? 1.05 : 1, - child: NftImage(imagePath: widget.nft.imageUrl), + child: NftImage(imageUrl: widget.nft.imageUrl), ), ), @@ -117,29 +114,19 @@ class _NftAmount extends StatelessWidget { shape: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(12), - child: Text( - nft.amount, - style: Theme.of(context).textTheme.bodyLarge, - ), + child: Text(nft.amount, style: Theme.of(context).textTheme.bodyLarge), ), ); } } class _NftData extends StatelessWidget { - const _NftData({ - required this.nft, - required this.onSendTap, - }); + const _NftData({required this.nft, required this.onSendTap}); final NftToken nft; final VoidCallback onSendTap; - Text _tileText(String text) => Text( - text, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - ); + Text _tileText(String text) => + Text(text, maxLines: 1, softWrap: false, overflow: TextOverflow.fade); @override Widget build(BuildContext context) { diff --git a/lib/views/nfts/nft_page.dart b/lib/views/nfts/nft_page.dart index a9bec724d4..7a7660d415 100644 --- a/lib/views/nfts/nft_page.dart +++ b/lib/views/nfts/nft_page.dart @@ -40,10 +40,7 @@ class NftPage extends StatelessWidget { ), ), ], - child: NFTPageView( - pageState: pageState, - uuid: uuid, - ), + child: NFTPageView(pageState: pageState, uuid: uuid), ), ); }, @@ -86,36 +83,40 @@ class _NFTPageViewState extends State { !_loggedOpen && curr.isInitialized && !prev.isInitialized, listener: (context, state) { _loggedOpen = true; - final count = state.nftCount.values - .fold(0, (sum, item) => sum + (item ?? 0)); + final count = state.nftCount.values.fold( + 0, + (sum, item) => sum + (item ?? 0), + ); context.read().logEvent( - NftGalleryOpenedEventData( - nftCount: count, - loadTimeMs: _loadStopwatch.elapsedMilliseconds, - ), - ); + NftGalleryOpenedEventData( + nftCount: count, + loadTimeMs: _loadStopwatch.elapsedMilliseconds, + ), + ); }, child: PageLayout( header: null, content: Expanded( child: Container( margin: isMobile ? const EdgeInsets.only(top: 14) : null, - child: Builder(builder: (context) { - switch (widget.pageState) { - case NFTSelectedState.details: - case NFTSelectedState.send: - return NftDetailsPage( - uuid: widget.uuid, - isSend: widget.pageState == NFTSelectedState.send, - ); - case NFTSelectedState.receive: - return const NftReceivePage(); - case NFTSelectedState.transactions: - return const NftListOfTransactionsPage(); - case NFTSelectedState.none: - return const NftMain(); - } - }), + child: Builder( + builder: (context) { + switch (widget.pageState) { + case NFTSelectedState.details: + case NFTSelectedState.send: + return NftDetailsPage( + uuid: widget.uuid, + isSend: widget.pageState == NFTSelectedState.send, + ); + case NFTSelectedState.receive: + return const NftReceivePage(); + case NFTSelectedState.transactions: + return const NftListOfTransactionsPage(); + case NFTSelectedState.none: + return const NftMain(); + } + }, + ), ), ), ), diff --git a/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart index a5871d65ce..6951ef5da6 100644 --- a/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart +++ b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart @@ -28,7 +28,7 @@ class NftTxnMedia extends StatelessWidget { children: [ ConstrainedBox( constraints: const BoxConstraints(maxWidth: 40, maxHeight: 40), - child: NftImage(imagePath: imagePath), + child: NftImage(imageUrl: imagePath), ), const SizedBox(width: 8), Expanded( @@ -41,24 +41,28 @@ class NftTxnMedia extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: Text(title ?? '-', - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: titleTextStyle), + child: Text( + title ?? '-', + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: titleTextStyle, + ), ), Text(' ($amount)', maxLines: 1, style: titleTextStyle), ], ), ), - Text(collectionName, - maxLines: 1, - overflow: TextOverflow.fade, - softWrap: false, - style: subtitleTextStyle), + Text( + collectionName, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: subtitleTextStyle, + ), ], ), - ) + ), ], ); } diff --git a/test_units/main.dart b/test_units/main.dart index 488b340301..a5e7ff8be3 100644 --- a/test_units/main.dart +++ b/test_units/main.dart @@ -33,6 +33,7 @@ import 'tests/utils/convert_double_to_string_test.dart'; import 'tests/utils/convert_fract_rat_test.dart'; import 'tests/utils/double_to_string_test.dart'; import 'tests/utils/get_fiat_amount_tests.dart'; +import 'tests/utils/ipfs_gateway_manager_test.dart'; import 'tests/utils/transaction_history/sanitize_transaction_test.dart'; import 'tests/swaps/my_recent_swaps_response_test.dart'; @@ -72,6 +73,7 @@ void main() { testDoubleToString(); testSanitizeTransaction(); + testIpfsGatewayManager(); }); group('Helpers: ', () { diff --git a/test_units/tests/utils/ipfs_gateway_manager_test.dart b/test_units/tests/utils/ipfs_gateway_manager_test.dart new file mode 100644 index 0000000000..4649e485b5 --- /dev/null +++ b/test_units/tests/utils/ipfs_gateway_manager_test.dart @@ -0,0 +1,465 @@ +import 'package:flutter/foundation.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/shared/constants/ipfs_constants.dart'; +import 'package:web_dex/shared/utils/ipfs_gateway_manager.dart'; + +void main() { + testIpfsGatewayManager(); +} + +void testIpfsGatewayManager() { + group('IpfsGatewayManager', () { + late IpfsGatewayManager manager; + + setUp(() { + manager = IpfsGatewayManager(); + }); + + group('Constructor and Configuration', () { + test('should use default gateways when none provided', () { + final manager = IpfsGatewayManager(); + + expect(manager.gateways, isNotEmpty); + // Should use web-optimized or standard based on platform + if (kIsWeb) { + expect(manager.gateways, + equals(IpfsConstants.defaultWebOptimizedGateways)); + } else { + expect( + manager.gateways, equals(IpfsConstants.defaultStandardGateways)); + } + }); + + test('should use custom gateways when provided', () { + final customWebGateways = ['https://custom-web.gateway.com/ipfs/']; + final customStandardGateways = [ + 'https://custom-standard.gateway.com/ipfs/' + ]; + + final manager = IpfsGatewayManager( + webOptimizedGateways: customWebGateways, + standardGateways: customStandardGateways, + ); + + if (kIsWeb) { + expect(manager.gateways, equals(customWebGateways)); + } else { + expect(manager.gateways, equals(customStandardGateways)); + } + }); + + test('should use custom failure cooldown when provided', () async { + const customCooldown = Duration(minutes: 10); + final manager = IpfsGatewayManager(failureCooldown: customCooldown); + + // Test cooldown by marking a URL as failed and checking timing + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + await manager.logGatewayAttempt(testUrl, false); + + expect(await manager.shouldSkipUrl(testUrl), isTrue); + }); + }); + + group('IPFS URL Detection', () { + test('should detect ipfs:// protocol URLs', () { + expect( + IpfsGatewayManager.isIpfsUrl( + 'ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('ipfs://QmTest/image.png'), isTrue); + }); + + test('should detect gateway format URLs', () { + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://gateway.pinata.cloud/ipfs/QmTest/metadata.json'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('https://dweb.link/ipfs/QmTest'), + isTrue); + }); + + test('should detect subdomain format URLs', () { + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o.ipfs.dweb.link'), + isTrue); + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://QmTest.ipfs.gateway.com/image.png'), + isTrue); + }); + + test('should detect URLs with /ipfs/ path anywhere', () { + expect( + IpfsGatewayManager.isIpfsUrl('https://some.domain.com/ipfs/QmTest'), + isTrue); + expect( + IpfsGatewayManager.isIpfsUrl( + 'https://custom-gateway.com/ipfs/QmTest/file.json'), + isTrue); + }); + + test('should not detect regular HTTP URLs as IPFS', () { + expect(IpfsGatewayManager.isIpfsUrl('https://example.com/image.png'), + isFalse); + expect(IpfsGatewayManager.isIpfsUrl('https://api.example.com/data'), + isFalse); + expect(IpfsGatewayManager.isIpfsUrl('http://localhost:3000/test'), + isFalse); + }); + + test('should handle null and empty URLs', () { + expect(IpfsGatewayManager.isIpfsUrl(null), isFalse); + expect(IpfsGatewayManager.isIpfsUrl(''), isFalse); + expect(IpfsGatewayManager.isIpfsUrl(' '), isFalse); + }); + }); + + group('Content ID Extraction', () { + test('should extract CID from ipfs:// protocol', () { + final urls = manager.getGatewayUrls( + 'ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'); + expect(urls.isNotEmpty, isTrue); + expect( + urls.first + .contains('QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + }); + + test('should extract CID and path from ipfs:// protocol', () { + final urls = manager.getGatewayUrls('ipfs://QmTest/metadata.json'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/metadata.json'), isTrue); + }); + + test('should extract CID from gateway format', () { + final urls = manager.getGatewayUrls( + 'https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'); + expect(urls.isNotEmpty, isTrue); + expect( + urls.first + .contains('QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + }); + + test('should extract CID and path from gateway format', () { + final urls = manager.getGatewayUrls( + 'https://gateway.pinata.cloud/ipfs/QmTest/image.png'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/image.png'), isTrue); + }); + + test('should extract CID from subdomain format', () { + final urls = manager.getGatewayUrls( + 'https://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o.ipfs.dweb.link'); + expect(urls.isNotEmpty, isTrue); + expect( + urls.first + .contains('QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'), + isTrue); + }); + + test('should extract CID and path from subdomain format', () { + final urls = manager.getGatewayUrls( + 'https://QmTest.ipfs.gateway.com/path/to/file.json'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/path/to/file.json'), isTrue); + }); + + test('should handle URLs with /ipfs/ path', () { + final urls = manager + .getGatewayUrls('https://custom.gateway.com/ipfs/QmTest/data'); + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains('QmTest/data'), isTrue); + }); + + test('should extract CID from case-insensitive URLs', () { + // Test case-insensitive protocol + final urls1 = manager.getGatewayUrls('IPFS://QmTest'); + expect(urls1.isNotEmpty, isTrue); + expect(urls1.first.contains('QmTest'), isTrue); + + // Test case-insensitive /ipfs/ path + final urls2 = manager.getGatewayUrls('https://gateway.com/IPFS/QmTest'); + expect(urls2.isNotEmpty, isTrue); + expect(urls2.first.contains('QmTest'), isTrue); + }); + }); + + group('Gateway URL Generation', () { + test('should generate multiple gateway URLs for IPFS content', () { + final urls = manager.getGatewayUrls('ipfs://QmTest'); + + expect(urls.length, equals(manager.gateways.length)); + for (int i = 0; i < urls.length; i++) { + expect(urls[i], equals('${manager.gateways[i]}QmTest')); + } + }); + + test('should return original URL for non-IPFS URLs', () { + const originalUrl = 'https://example.com/image.png'; + final urls = manager.getGatewayUrls(originalUrl); + + expect(urls.length, equals(1)); + expect(urls.first, equals(originalUrl)); + }); + + test('should return empty list for null/empty URLs', () { + expect(manager.getGatewayUrls(null), isEmpty); + expect(manager.getGatewayUrls(''), isEmpty); + }); + + test('should get primary gateway URL', () { + final primaryUrl = manager.getPrimaryGatewayUrl('ipfs://QmTest'); + expect(primaryUrl, equals('${manager.gateways.first}QmTest')); + }); + + test('should return null for primary URL when input is invalid', () { + expect(manager.getPrimaryGatewayUrl(null), isNull); + expect(manager.getPrimaryGatewayUrl(''), isNull); + }); + }); + + group('URL Normalization', () { + test('should normalize different IPFS URL formats to preferred gateway', + () { + const cid = 'QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o'; + final expectedUrl = '${manager.gateways.first}$cid'; + + expect(manager.normalizeIpfsUrl('ipfs://$cid'), equals(expectedUrl)); + expect(manager.normalizeIpfsUrl('https://ipfs.io/ipfs/$cid'), + equals(expectedUrl)); + expect(manager.normalizeIpfsUrl('https://$cid.ipfs.dweb.link'), + equals(expectedUrl)); + }); + + test('should preserve paths in normalized URLs', () { + const cidWithPath = 'QmTest/metadata.json'; + final expectedUrl = '${manager.gateways.first}$cidWithPath'; + + expect(manager.normalizeIpfsUrl('ipfs://$cidWithPath'), + equals(expectedUrl)); + expect( + manager.normalizeIpfsUrl( + 'https://gateway.pinata.cloud/ipfs/$cidWithPath'), + equals(expectedUrl)); + }); + }); + + group('Failure Tracking and Circuit Breaker', () { + test('should track failed URLs', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + + expect(await manager.shouldSkipUrl(testUrl), isFalse); + + await manager.logGatewayAttempt(testUrl, false); + expect(await manager.shouldSkipUrl(testUrl), isTrue); + }); + + test('should remove URLs from failed set on success', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + + await manager.logGatewayAttempt(testUrl, false); + expect(await manager.shouldSkipUrl(testUrl), isTrue); + + await manager.logGatewayAttempt(testUrl, true); + expect(await manager.shouldSkipUrl(testUrl), isFalse); + }); + + test('should respect failure cooldown period', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + final shortCooldownManager = IpfsGatewayManager( + failureCooldown: const Duration(milliseconds: 100), + ); + + await shortCooldownManager.logGatewayAttempt(testUrl, false); + expect(await shortCooldownManager.shouldSkipUrl(testUrl), isTrue); + + // Wait for cooldown to expire + await Future.delayed(const Duration(milliseconds: 150)); + expect(await shortCooldownManager.shouldSkipUrl(testUrl), isFalse); + }); + + test('should filter out failed URLs from reliable gateway URLs', + () async { + const originalUrl = 'ipfs://QmTest'; + final allUrls = manager.getGatewayUrls(originalUrl); + + if (allUrls.isNotEmpty) { + // Mark first gateway as failed + await manager.logGatewayAttempt(allUrls.first, false); + + final reliableUrls = + await manager.getReliableGatewayUrls(originalUrl); + expect(reliableUrls.length, equals(allUrls.length - 1)); + expect(reliableUrls.contains(allUrls.first), isFalse); + } + }); + }); + + group('Edge Cases and Error Handling', () { + test('should handle malformed URLs gracefully', () { + const malformedUrls = [ + 'ipfs://', + 'ipfs:///', + 'https://ipfs.io/ipfs/', + 'https://.ipfs.dweb.link', + 'not-a-url', + 'ftp://example.com/file', + ]; + + for (final url in malformedUrls) { + expect(() => manager.getGatewayUrls(url), returnsNormally); + expect(() => manager.normalizeIpfsUrl(url), returnsNormally); + } + }); + + test('should handle very long URLs', () { + final longCid = + 'QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o${'A' * 100}'; + final urls = manager.getGatewayUrls('ipfs://$longCid'); + + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains(longCid), isTrue); + }); + + test('should handle URLs with special characters', () { + const specialPath = + 'QmTest/file%20with%20spaces.json?param=value#anchor'; + final urls = manager.getGatewayUrls('ipfs://$specialPath'); + + expect(urls.isNotEmpty, isTrue); + expect(urls.first.contains(specialPath), isTrue); + }); + + test('should handle case variations in URL schemes', () { + // URL schemes and paths should be case-insensitive + expect(IpfsGatewayManager.isIpfsUrl('IPFS://QmTest'), isTrue); + expect(IpfsGatewayManager.isIpfsUrl('Ipfs://QmTest'), isTrue); + expect(IpfsGatewayManager.isIpfsUrl('ipfs://QmTest'), isTrue); + // Gateway URLs with different case should work + expect(IpfsGatewayManager.isIpfsUrl('HTTPS://gateway.com/IPFS/QmTest'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('https://gateway.com/ipfs/QmTest'), + isTrue); + expect(IpfsGatewayManager.isIpfsUrl('https://gateway.com/Ipfs/QmTest'), + isTrue); + }); + }); + + group('Real-world Examples', () { + group('NFT Metadata URLs', () { + test('should handle typical NFT metadata IPFS URLs', () { + const examples = [ + 'ipfs://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o', + 'ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1', + 'https://ipfs.io/ipfs/QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o', + 'https://gateway.pinata.cloud/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/metadata.json', + ]; + + for (final example in examples) { + final urls = manager.getGatewayUrls(example); + expect(urls.isNotEmpty, isTrue, reason: 'Failed for: $example'); + expect(IpfsGatewayManager.isIpfsUrl(example), isTrue, + reason: 'Not detected as IPFS: $example'); + } + }); + + test('should handle subdomain IPFS URLs from popular services', () { + const examples = [ + 'https://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi.ipfs.dweb.link', + 'https://QmYjtig7VJQ6XsnUjqqJvj7QaMcCAwtrgNdahSiFofrE7o.ipfs.nftstorage.link', + 'https://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS.ipfs.w3s.link/metadata.json', + ]; + + for (final example in examples) { + final urls = manager.getGatewayUrls(example); + expect(urls.isNotEmpty, isTrue, reason: 'Failed for: $example'); + expect(IpfsGatewayManager.isIpfsUrl(example), isTrue, + reason: 'Not detected as IPFS: $example'); + } + }); + }); + + group('Non-IPFS URLs', () { + test('should handle regular image URLs', () { + const examples = [ + 'https://example.com/image.png', + 'https://cdn.example.com/assets/logo.svg', + 'https://api.example.com/v1/image/123.jpg', + 'http://localhost:3000/test-image.gif', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + ]; + + for (final example in examples) { + expect(IpfsGatewayManager.isIpfsUrl(example), isFalse, + reason: 'Incorrectly detected as IPFS: $example'); + final urls = manager.getGatewayUrls(example); + expect(urls.length, equals(1), + reason: 'Should return original URL: $example'); + expect(urls.first, equals(example), + reason: 'Should return unchanged: $example'); + } + }); + }); + + group('Invalid/Broken URLs', () { + test('should handle invalid URLs gracefully', () { + const invalidExamples = [ + '', + ' ', + 'not-a-url', + '://missing-scheme', + 'https://', + 'ipfs://', + 'javascript:alert("xss")', + 'file:///etc/passwd', + ]; + + for (final example in invalidExamples) { + expect(() => manager.getGatewayUrls(example), returnsNormally, + reason: 'Should not throw for: $example'); + expect(() => IpfsGatewayManager.isIpfsUrl(example), returnsNormally, + reason: 'Should not throw for: $example'); + } + }); + }); + }); + + group('Performance and Logging', () { + test('should log gateway attempts with success', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + const loadTime = Duration(milliseconds: 250); + + // Should not throw + await expectLater( + manager.logGatewayAttempt( + testUrl, + true, + loadTime: loadTime, + ), + completes, + ); + }); + + test('should log gateway attempts with failure', () async { + const testUrl = 'https://test.gateway.com/ipfs/QmTest'; + const errorMessage = 'Connection timeout'; + + // Should not throw + await expectLater( + manager.logGatewayAttempt( + testUrl, + false, + errorMessage: errorMessage, + ), + completes, + ); + }); + }); + }); +}