diff --git a/AGENTS.md b/AGENTS.md index 68276a0e41..279d16135e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,13 +12,7 @@ If the above fails due to the offline environment, add the `--offline` flag. ## Static Analysis and Formatting -Run analysis and formatting before committing code: - -```bash -flutter analyze - -dart format . -``` +Run analysis and formatting (only on changed files) before committing code: ## Running Tests @@ -48,6 +42,6 @@ The KDF API documentation can be found in the root folder at `/KDF_API_DOCUMENTA ## PR Guidance -Commit messages should be clear and descriptive. When opening a pull request, summarize the purpose of the change and reference related issues when appropriate. Ensure commit messages follow the Conventional Commits standard as described in the standards section below. +Commit messages should be clear and descriptive. When opening a pull request, summarize the purpose of the change and reference related issues when appropriate. Ensure commit messages and PR title follow the Conventional Commits standard as described in the standards section below. diff --git a/assets/translations/en.json b/assets/translations/en.json index 80c2e8a79e..3e7dda38f3 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -744,6 +744,7 @@ "trend7d": "7d trend", "tradingDisabledTooltip": "Trading features are currently disabled", "tradingDisabled": "Trading unavailable in your location", + "includeBlockedAssets": "Include blocked assets", "unbanPubkeysResults": "Unban Pubkeys Results", "unbannedPubkeys": { "zero": "{} Unbanned Pubkeys", diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 4cee603b84..533a8ac891 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -47,7 +47,7 @@ import 'package:web_dex/bloc/system_health/system_clock_repository.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; -import 'package:web_dex/bloc/trading_status/trading_status_repository.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; import 'package:web_dex/bloc/version_info/version_info_bloc.dart'; @@ -176,13 +176,15 @@ class AppBlocRoot extends StatelessWidget { RepositoryProvider( create: (_) => KmdRewardsBloc(coinsRepository, mm2Api), ), - RepositoryProvider(create: (_) => TradingStatusRepository()), ], child: MultiBlocProvider( providers: [ BlocProvider( - create: (context) => - CoinsBloc(komodoDefiSdk, coinsRepository)..add(CoinsStarted()), + create: (context) => CoinsBloc( + komodoDefiSdk, + coinsRepository, + context.read(), + )..add(CoinsStarted()), ), BlocProvider( create: (context) => PriceChartBloc(komodoDefiSdk) @@ -268,8 +270,8 @@ class AppBlocRoot extends StatelessWidget { BlocProvider( lazy: false, create: (context) => - TradingStatusBloc(context.read()) - ..add(TradingStatusCheckRequested()), + TradingStatusBloc(context.read()) + ..add(TradingStatusWatchStarted()), ), BlocProvider( create: (_) => diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index 856db153ef..fb7f1b857a 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -9,6 +9,7 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/blocs/wallets_repository.dart'; import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; @@ -23,8 +24,12 @@ part 'trezor_auth_mixin.dart'; class AuthBloc extends Bloc with TrezorAuthMixin { /// Handles [AuthBlocEvent]s and emits [AuthBlocState]s. /// [_kdfSdk] is an instance of [KomodoDefiSdk] used for authentication. - AuthBloc(this._kdfSdk, this._walletsRepository, this._settingsRepository) - : super(AuthBlocState.initial()) { + AuthBloc( + this._kdfSdk, + this._walletsRepository, + this._settingsRepository, + this._tradingStatusService, + ) : super(AuthBlocState.initial()) { on(_onAuthChanged); on(_onClearState); on(_onLogout); @@ -41,6 +46,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { final KomodoDefiSdk _kdfSdk; final WalletsRepository _walletsRepository; final SettingsRepository _settingsRepository; + final TradingStatusService _tradingStatusService; StreamSubscription? _authChangesSubscription; @override final _log = Logger('AuthBloc'); @@ -48,6 +54,26 @@ class AuthBloc extends Bloc with TrezorAuthMixin { @override KomodoDefiSdk get _sdk => _kdfSdk; + /// Filters out geo-blocked assets from a list of coin IDs. + /// This ensures that blocked assets are not added to wallet metadata during + /// registration or restoration. + /// + /// TODO: UX Improvement - For faster wallet creation/restoration, consider + /// adding all default coins to metadata initially, then removing blocked ones + /// when bouncer status is confirmed. This would require: + /// 1. Reactive metadata updates when trading status changes + /// 2. Coordinated cleanup across wallet metadata and activated coins + /// 3. Handling edge cases where user manually re-adds a blocked coin + /// See TradingStatusService._currentStatus for related startup optimizations. + @override + List _filterBlockedAssets(List coinIds) { + return coinIds.where((coinId) { + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) return true; // Keep unknown assets for now + return !_tradingStatusService.isAssetBlocked(assets.single.id); + }).toList(); + } + @override Future close() async { await _authChangesSubscription?.cancel(); @@ -186,7 +212,9 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ); await _kdfSdk.setWalletType(event.wallet.config.type); await _kdfSdk.confirmSeedBackup(hasBackup: false); - await _kdfSdk.addActivatedCoins(enabledByDefaultCoins); + // Filter out geo-blocked assets from default coins before adding to wallet + final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); + await _kdfSdk.addActivatedCoins(allowedDefaultCoins); final currentUser = await _kdfSdk.auth.currentUser; if (currentUser == null) { @@ -242,14 +270,18 @@ class AuthBloc extends Bloc with TrezorAuthMixin { ); await _kdfSdk.setWalletType(event.wallet.config.type); await _kdfSdk.confirmSeedBackup(hasBackup: event.wallet.config.hasBackup); - await _kdfSdk.addActivatedCoins(enabledByDefaultCoins); + // Filter out geo-blocked assets from default coins before adding to wallet + final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); + await _kdfSdk.addActivatedCoins(allowedDefaultCoins); if (event.wallet.config.activatedCoins.isNotEmpty) { // Seed import files and legacy wallets may contain removed or unsupported // coins, so we filter them out before adding them to the wallet metadata. final availableWalletCoins = _filterOutUnsupportedCoins( event.wallet.config.activatedCoins, ); - await _kdfSdk.addActivatedCoins(availableWalletCoins); + // Also filter out geo-blocked assets from restored wallet coins + final allowedWalletCoins = _filterBlockedAssets(availableWalletCoins); + await _kdfSdk.addActivatedCoins(allowedWalletCoins); } // Delete legacy wallet on successful restoration & login to avoid diff --git a/lib/bloc/auth_bloc/trezor_auth_mixin.dart b/lib/bloc/auth_bloc/trezor_auth_mixin.dart index 877a5ba998..e516353eb0 100644 --- a/lib/bloc/auth_bloc/trezor_auth_mixin.dart +++ b/lib/bloc/auth_bloc/trezor_auth_mixin.dart @@ -3,7 +3,7 @@ part of 'auth_bloc.dart'; /// Mixin that exposes Trezor authentication helpers for [AuthBloc]. mixin TrezorAuthMixin on Bloc { KomodoDefiSdk get _sdk; - final _log = Logger('TrezorAuthMixin'); + Logger get _log; /// Registers handlers for Trezor specific events. void setupTrezorEventHandlers() { @@ -17,6 +17,10 @@ mixin TrezorAuthMixin on Bloc { /// to authentication state changes. void _listenToAuthStateChanges(); + /// Filters out geo-blocked assets from a list of coin IDs. + /// Implemented in [AuthBloc]. + List _filterBlockedAssets(List coinIds); + Future _onTrezorInitAndAuth( AuthTrezorInitAndAuthStarted event, Emitter emit, @@ -130,7 +134,9 @@ mixin TrezorAuthMixin on Bloc { if (authState.user!.wallet.config.activatedCoins.isEmpty) { // If no coins are activated, we assume this is the first time // the user is setting up their Trezor wallet. - await _sdk.addActivatedCoins(enabledByDefaultCoins); + // Filter out geo-blocked assets from default coins before adding to wallet + final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); + await _sdk.addActivatedCoins(allowedDefaultCoins); } // Refresh the current user to pull in the updated wallet metadata diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 1736be88df..59cf2c6acf 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -9,6 +9,7 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; @@ -20,7 +21,8 @@ part 'coins_state.dart'; /// Responsible for coin activation, deactivation, syncing, and fiat price class CoinsBloc extends Bloc { - CoinsBloc(this._kdfSdk, this._coinsRepo) : super(CoinsState.initial()) { + CoinsBloc(this._kdfSdk, this._coinsRepo, this._tradingStatusService) + : super(CoinsState.initial()) { on(_onCoinsStarted, transformer: droppable()); // TODO: move auth listener to ui layer: bloclistener should fire auth events on(_onCoinsBalanceMonitoringStarted); @@ -40,6 +42,7 @@ class CoinsBloc extends Bloc { final KomodoDefiSdk _kdfSdk; final CoinsRepo _coinsRepo; + final TradingStatusService _tradingStatusService; final _log = Logger('CoinsBloc'); @@ -82,6 +85,18 @@ class CoinsBloc extends Bloc { CoinsStarted event, Emitter emit, ) async { + // Wait for trading status service to receive initial status before + // populating coins list. This ensures geo-blocked assets are properly + // filtered from the start, preventing them from appearing in the UI + // before filtering is applied. + // + // TODO: UX Improvement - For faster startup, populate coins immediately + // and reactively filter when trading status updates arrive. This would + // eliminate startup delay (~100-500ms) but requires UI to handle dynamic + // removal of blocked assets. See TradingStatusService._currentStatus for + // related trade-offs. + await _tradingStatusService.initialStatusReady; + emit(state.copyWith(coins: _coinsRepo.getKnownCoinsMap())); final existingUser = await _kdfSdk.auth.currentUser; @@ -320,9 +335,17 @@ class CoinsBloc extends Bloc { // Start off by emitting the newly activated coins so that they all appear // in the list at once, rather than one at a time as they are activated - final coinsToActivate = currentWallet.config.activatedCoins; - emit(_prePopulateListWithActivatingCoins(coinsToActivate)); - await _activateCoins(coinsToActivate, emit); + final allCoinsToActivate = currentWallet.config.activatedCoins; + + // Filter out blocked coins before activation + final allowedCoins = allCoinsToActivate.where((coinId) { + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) return false; + return !_tradingStatusService.isAssetBlocked(assets.single.id); + }); + + emit(_prePopulateListWithActivatingCoins(allowedCoins)); + await _activateCoins(allowedCoins, emit); add(CoinsBalancesRefreshed()); add(CoinsBalanceMonitoringStarted()); @@ -361,11 +384,17 @@ class CoinsBloc extends Bloc { // activation loops for assets not supported by the SDK.this may happen if the wallet // has assets that were removed from the SDK or the config has unsupported default // assets. - // This is also important for coin delistings. - final enableFutures = coins + final availableAssets = coins .map((coin) => _kdfSdk.assets.findAssetsByConfigId(coin)) - .where((assets) => assets.isNotEmpty) - .map((assets) => assets.single) + .where((assetsSet) => assetsSet.isNotEmpty) + .map((assetsSet) => assetsSet.single); + + // Filter out blocked assets + final coinsToActivate = _tradingStatusService.filterAllowedAssets( + availableAssets.toList(), + ); + + final enableFutures = coinsToActivate .map((asset) => _coinsRepo.activateAssetsSync([asset])) .toList(); diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 32e142500b..101c76079a 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -14,6 +14,8 @@ import 'package:komodo_ui/komodo_ui.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart' show excludedAssetList; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart' + show TradingStatusService; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; @@ -29,9 +31,13 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; class CoinsRepo { - CoinsRepo({required KomodoDefiSdk kdfSdk, required MM2 mm2}) - : _kdfSdk = kdfSdk, - _mm2 = mm2 { + CoinsRepo({ + required KomodoDefiSdk kdfSdk, + required MM2 mm2, + required TradingStatusService tradingStatusService, + }) : _kdfSdk = kdfSdk, + _mm2 = mm2, + _tradingStatusService = tradingStatusService { enabledAssetsChanges = StreamController.broadcast( onListen: () => _enabledAssetListenerCount += 1, onCancel: () => _enabledAssetListenerCount -= 1, @@ -40,6 +46,7 @@ class CoinsRepo { final KomodoDefiSdk _kdfSdk; final MM2 _mm2; + final TradingStatusService _tradingStatusService; final _log = Logger('CoinsRepo'); @@ -86,6 +93,11 @@ class CoinsRepo { // Cancel any existing subscription for this asset _balanceWatchers[asset.id]?.cancel(); + if (_tradingStatusService.isAssetBlocked(asset.id)) { + _log.info('Asset ${asset.id.id} is blocked. Skipping balance updates.'); + return; + } + // Start a new subscription _balanceWatchers[asset.id] = _kdfSdk.balances.watchBalance(asset.id).listen( (balanceInfo) { @@ -128,7 +140,11 @@ class CoinsRepo { if (excludeExcludedAssets) { assets.removeWhere((key, _) => excludedAssetList.contains(key.id)); } - return assets.values.map(_assetToCoinWithoutAddress).toList(); + // Filter out blocked assets + final allowedAssets = _tradingStatusService.filterAllowedAssets( + assets.values.toList(), + ); + return allowedAssets.map(_assetToCoinWithoutAddress).toList(); } /// Returns a map of all known coins, optionally filtering out excluded assets. @@ -139,8 +155,11 @@ class CoinsRepo { if (excludeExcludedAssets) { assets.removeWhere((key, _) => excludedAssetList.contains(key.id)); } + final allowedAssets = _tradingStatusService.filterAllowedAssets( + assets.values.toList(), + ); return Map.fromEntries( - assets.values.map( + allowedAssets.map( (asset) => MapEntry(asset.id.id, _assetToCoinWithoutAddress(asset)), ), ); @@ -180,7 +199,7 @@ class CoinsRepo { return []; } - return currentUser.wallet.config.activatedCoins + final walletAssets = currentUser.wallet.config.activatedCoins .map((coinId) { final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); if (assets.isEmpty) { @@ -196,6 +215,10 @@ class CoinsRepo { return assets.single; }) .whereType() + .toList(); + + return _tradingStatusService + .filterAllowedAssets(walletAssets) .map(_assetToCoinWithoutAddress) .toList(); } @@ -564,11 +587,16 @@ class CoinsRepo { // Filter out excluded and testnet assets, as they are not expected // to have valid prices available at any of the providers - final validAssets = targetAssets + final filteredAssets = targetAssets .where((asset) => !excludedAssetList.contains(asset.id.id)) .where((asset) => !asset.protocol.isTestnet) .toList(); + // Filter out blocked assets + final validAssets = _tradingStatusService.filterAllowedAssets( + filteredAssets, + ); + // Process assets with bounded parallelism to avoid overwhelming providers await _fetchAssetPricesInChunks(validAssets); @@ -625,7 +653,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 + final coins = _tradingStatusService + .filterAllowedAssetsMap(walletCoinsCopy, (coin) => coin.id) + .values .where((coin) => coin.isActive) .toList(); diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart index d253b8f667..ff4e37d72d 100644 --- a/lib/bloc/coins_manager/coins_manager_bloc.dart +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -62,7 +62,7 @@ class CoinsManagerBloc extends Bloc { ) async { final List filters = []; - final mergedCoinsList = mergeCoinLists( + final mergedCoinsList = _mergeCoinLists( await _getOriginalCoinList(_coinsRepo, event.action), state.coins, ).toList(); @@ -330,7 +330,7 @@ class CoinsManagerBloc extends Bloc { return result; } - Set mergeCoinLists(List originalList, List newList) { + Set _mergeCoinLists(List originalList, List newList) { final Map coinMap = {}; for (final Coin coin in originalList) { diff --git a/lib/bloc/trading_status/app_geo_status.dart b/lib/bloc/trading_status/app_geo_status.dart new file mode 100644 index 0000000000..ace28a6a37 --- /dev/null +++ b/lib/bloc/trading_status/app_geo_status.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart' show Equatable; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; + +/// Structured status returned by the bouncer service. +class AppGeoStatus extends Equatable { + const AppGeoStatus({ + this.disallowedAssets = const {}, + this.disallowedFeatures = const {}, + }); + + /// Assets that are disallowed in the current geo location. + final Set disallowedAssets; + + /// Features that are disallowed in the current geo location. + final Set disallowedFeatures; + + /// Whether trading is enabled based on the current geo status. + bool get tradingEnabled => + !disallowedFeatures.contains(DisallowedFeature.trading); + + bool isAssetBlocked(AssetId asset) { + return disallowedAssets.contains(asset); + } + + @override + List get props => [disallowedAssets, disallowedFeatures]; +} diff --git a/lib/bloc/trading_status/disallowed_feature.dart b/lib/bloc/trading_status/disallowed_feature.dart new file mode 100644 index 0000000000..d968ec28a3 --- /dev/null +++ b/lib/bloc/trading_status/disallowed_feature.dart @@ -0,0 +1,12 @@ +enum DisallowedFeature { + trading; + + static DisallowedFeature parse(String value) { + switch (value.toUpperCase()) { + case 'TRADING': + return DisallowedFeature.trading; + default: + throw ArgumentError.value(value, 'value', 'Invalid disallowed feature'); + } + } +} diff --git a/lib/bloc/trading_status/trading_status_api_provider.dart b/lib/bloc/trading_status/trading_status_api_provider.dart new file mode 100644 index 0000000000..2d39068b08 --- /dev/null +++ b/lib/bloc/trading_status/trading_status_api_provider.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:web_dex/shared/constants.dart'; + +/// Provider responsible for making API calls to trading status endpoints. +class TradingStatusApiProvider { + TradingStatusApiProvider({http.Client? httpClient, Duration? timeout}) + : _httpClient = httpClient ?? http.Client(), + _timeout = timeout ?? const Duration(seconds: 10); + + final http.Client _httpClient; + final Duration _timeout; + final Logger _log = Logger('TradingStatusApiProvider'); + + static const String _apiKeyHeader = 'X-KW-KEY'; + + /// Fetches trading status from the geo blocker API. + /// + /// Throws [TimeoutException] on timeout. + /// Throws [http.ClientException] on HTTP client errors. + /// Throws [FormatException] on JSON parsing errors. + Future> fetchGeoStatus({required String apiKey}) async { + _log.fine('Fetching geo status from API'); + + final uri = Uri.parse(geoBlockerApiUrl); + final headers = {_apiKeyHeader: apiKey}; + + try { + final response = await _httpClient + .post(uri, headers: headers) + .timeout(_timeout); + + _log.fine('Geo status API response: ${response.statusCode}'); + + if (response.statusCode != 200) { + _log.warning('Geo status API returned status ${response.statusCode}'); + throw http.ClientException( + 'API returned status ${response.statusCode}', + uri, + ); + } + + final data = json.decode(response.body) as Map; + _log.fine('Successfully parsed geo status response'); + return data; + } on TimeoutException catch (e) { + _log.warning('Geo status API request timed out: $e'); + rethrow; + } on http.ClientException catch (e) { + _log.warning('HTTP client error fetching geo status: ${e.message}'); + rethrow; + } on FormatException catch (e) { + _log.severe('Failed to parse geo status JSON response: $e'); + rethrow; + } + } + + /// Fetches trading blacklist for testing purposes. + /// + /// Throws [TimeoutException] on timeout. + /// Throws [http.ClientException] on HTTP client errors. + Future fetchTradingBlacklist() async { + _log.fine('Fetching trading blacklist for testing'); + + final uri = Uri.parse(tradingBlacklistUrl); + + try { + final response = await _httpClient + .post(uri, headers: const {}) + .timeout(_timeout); + + _log.fine('Trading blacklist API response: ${response.statusCode}'); + return response; + } on TimeoutException catch (e) { + _log.warning('Trading blacklist API request timed out: $e'); + rethrow; + } on http.ClientException catch (e) { + _log.warning( + 'HTTP client error fetching trading blacklist: ${e.message}', + ); + rethrow; + } + } + + void dispose() { + _httpClient.close(); + } +} diff --git a/lib/bloc/trading_status/trading_status_bloc.dart b/lib/bloc/trading_status/trading_status_bloc.dart index 9dcf613459..af91c31bcc 100644 --- a/lib/bloc/trading_status/trading_status_bloc.dart +++ b/lib/bloc/trading_status/trading_status_bloc.dart @@ -1,32 +1,63 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'trading_status_repository.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; +import 'package:web_dex/bloc/trading_status/app_geo_status.dart'; +import 'trading_status_service.dart'; part 'trading_status_event.dart'; part 'trading_status_state.dart'; class TradingStatusBloc extends Bloc { - TradingStatusBloc(this._repository) : super(TradingStatusInitial()) { + TradingStatusBloc(this._service) : super(TradingStatusInitial()) { on(_onCheckRequested); + on(_onWatchStarted); } - final TradingStatusRepository _repository; + final TradingStatusService _service; - // TODO (@takenagain): Retry periodically if the failure was caused by a - // network issue. Future _onCheckRequested( TradingStatusCheckRequested event, Emitter emit, ) async { emit(TradingStatusLoadInProgress()); try { - final enabled = await _repository.isTradingEnabled(); - emit(enabled ? TradingEnabled() : TradingDisabled()); - - // This catch will never be triggered by the repository. This will require - // changes to meet the "TODO" above. + final status = await _service.refreshStatus(); + emit( + TradingStatusLoadSuccess( + disallowedAssets: status.disallowedAssets, + disallowedFeatures: status.disallowedFeatures, + ), + ); } catch (_) { emit(TradingStatusLoadFailure()); } } + + Future _onWatchStarted( + TradingStatusWatchStarted event, + Emitter emit, + ) async { + emit(TradingStatusLoadInProgress()); + // Seed immediately with cached status if available; continue with stream. + try { + final status = _service.currentStatus; + emit( + TradingStatusLoadSuccess( + disallowedAssets: status.disallowedAssets, + disallowedFeatures: status.disallowedFeatures, + ), + ); + } catch (_) { + // Service not initialized yet; will emit once stream produces data. + } + await emit.forEach( + _service.statusStream, + onData: (AppGeoStatus status) => TradingStatusLoadSuccess( + disallowedAssets: status.disallowedAssets, + disallowedFeatures: status.disallowedFeatures, + ), + onError: (error, stackTrace) => TradingStatusLoadFailure(), + ); + } } diff --git a/lib/bloc/trading_status/trading_status_event.dart b/lib/bloc/trading_status/trading_status_event.dart index 77fdfd8971..ad033df8d9 100644 --- a/lib/bloc/trading_status/trading_status_event.dart +++ b/lib/bloc/trading_status/trading_status_event.dart @@ -5,4 +5,7 @@ abstract class TradingStatusEvent extends Equatable { List get props => []; } -class TradingStatusCheckRequested extends TradingStatusEvent {} +final class TradingStatusCheckRequested extends TradingStatusEvent {} + +/// Event emitted when the bloc should start watching trading status continuously +final class TradingStatusWatchStarted extends TradingStatusEvent {} diff --git a/lib/bloc/trading_status/trading_status_repository.dart b/lib/bloc/trading_status/trading_status_repository.dart index 1df10a1152..9027e80de9 100644 --- a/lib/bloc/trading_status/trading_status_repository.dart +++ b/lib/bloc/trading_status/trading_status_repository.dart @@ -1,61 +1,196 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; + +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; -import 'package:web_dex/shared/constants.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/trading_status/app_geo_status.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_api_provider.dart'; class TradingStatusRepository { - TradingStatusRepository({http.Client? httpClient, Duration? timeout}) - : _httpClient = httpClient ?? http.Client(), - _timeout = timeout ?? const Duration(seconds: 10); + TradingStatusRepository(this._sdk, {TradingStatusApiProvider? apiProvider}) + : _apiProvider = apiProvider ?? TradingStatusApiProvider(); - final http.Client _httpClient; - final Duration _timeout; + final TradingStatusApiProvider _apiProvider; + final Logger _log = Logger('TradingStatusRepository'); + final KomodoDefiSdk _sdk; - Future isTradingEnabled({bool? forceFail}) async { + /// Fetches geo status and computes trading availability. + /// + /// Rules: + /// - If GEO_BLOCK=disabled, trading is enabled. + /// - Otherwise, trading is disabled if disallowed_features contains 'TRADING'. + Future fetchStatus({bool? forceFail}) async { try { - final geoBlock = const String.fromEnvironment('GEO_BLOCK'); - if (geoBlock == 'disabled') { - debugPrint('GEO_BLOCK is disabled. Trading enabled.'); - return true; + if (_isGeoBlockDisabled()) { + _log.info('GEO_BLOCK is disabled. Trading enabled.'); + return const AppGeoStatus(); } - final apiKey = const String.fromEnvironment('FEEDBACK_API_KEY'); final bool shouldFail = forceFail ?? false; + final String apiKey = _readFeedbackApiKey(); if (apiKey.isEmpty && !shouldFail) { - debugPrint('FEEDBACK_API_KEY not found. Trading disabled.'); - return false; + _log.warning('FEEDBACK_API_KEY not found. Trading disabled.'); + return const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); } - late final Uri uri; - final headers = {}; - + late final JsonMap data; if (shouldFail) { - uri = Uri.parse(tradingBlacklistUrl); - } else { - uri = Uri.parse(geoBlockerApiUrl); - headers['X-KW-KEY'] = apiKey; + final res = await _apiProvider.fetchTradingBlacklist(); + return AppGeoStatus( + disallowedFeatures: res.statusCode == 200 + ? const {} + : const {DisallowedFeature.trading}, + ); } - final res = - await _httpClient.post(uri, headers: headers).timeout(_timeout); + data = await _apiProvider.fetchGeoStatus(apiKey: apiKey); - if (shouldFail) { - return res.statusCode == 200; + final featuresParsed = _parseFeatures(data); + final Set disallowedAssets = _parseAssets(data); + + // If the API omitted the disallowed_features field entirely, + // block trading by default to be conservative. + if (!featuresParsed.hasFeatures) { + _log.warning( + 'disallowed_features missing in response. Blocking trading.', + ); + return AppGeoStatus( + disallowedAssets: disallowedAssets, + disallowedFeatures: const { + DisallowedFeature.trading, + }, + ); } - if (res.statusCode != 200) return false; - final JsonMap data = jsonFromString(res.body); - return !(data.valueOrNull('blocked') ?? true); - } catch (_) { - debugPrint('Network error: Trading status check failed'); - // Block trading features on network failure - return false; + return AppGeoStatus( + disallowedAssets: disallowedAssets, + disallowedFeatures: featuresParsed.features, + ); + } on Exception catch (e, s) { + _log.severe('Unexpected error during trading status check', e, s); + return const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + } + } + + /// Backward-compatible helper for existing call sites. + Future isTradingEnabled({bool? forceFail}) async { + final status = await fetchStatus(forceFail: forceFail); + return status.tradingEnabled; + } + + /// Creates a stream that periodically polls for trading status using + /// Stream.periodic with fault tolerance. + /// + /// The stream emits immediately with the first status check, then continues + /// polling at the configured interval. Uses exponential backoff for error retry delays. + Stream watchTradingStatus({ + Duration pollingInterval = const Duration(minutes: 1), + BackoffStrategy? backoffStrategy, + bool? forceFail, + }) async* { + _log.info('Starting trading status polling stream'); + + backoffStrategy ??= ExponentialBackoff( + initialDelay: const Duration(seconds: 1), + maxDelay: const Duration(minutes: 5), + withJitter: true, + ); + + var consecutiveFailures = 0; + var currentDelay = Duration.zero; + + // Emit first status immediately + try { + final status = await fetchStatus(forceFail: forceFail); + yield status; + } catch (e) { + _log.warning('Error in initial trading status fetch: $e'); + yield const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + } + + // Use Stream.periodic for clean, reliable polling + await for (final _ in Stream.periodic(pollingInterval)) { + try { + final status = await fetchStatus(forceFail: forceFail); + yield status; + + // Reset failure tracking on successful fetch + if (consecutiveFailures > 0) { + consecutiveFailures = 0; + currentDelay = Duration.zero; + _log.info('Trading status fetch recovered, resuming normal polling'); + } + } catch (e) { + _log.warning('Error in trading status fetch: $e'); + yield const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + + // Apply exponential backoff delay for consecutive failures + currentDelay = backoffStrategy.nextDelay( + consecutiveFailures, + currentDelay, + ); + consecutiveFailures++; + + _log.info( + 'Backing off for ${currentDelay.inMilliseconds}ms (attempt $consecutiveFailures)', + ); + + // Add backoff delay before next poll + await Future.delayed(currentDelay); + } + } + } + + // --- Configuration helpers ------------------------------------------------- + String _readGeoBlockFlag() => const String.fromEnvironment('GEO_BLOCK'); + String _readFeedbackApiKey() => + const String.fromEnvironment('FEEDBACK_API_KEY'); + bool _isGeoBlockDisabled() => _readGeoBlockFlag() == 'disabled'; + + // --- Parsing helpers ------------------------------------------------------- + + ({Set features, bool hasFeatures}) _parseFeatures( + JsonMap data, + ) { + final List? raw = data.valueOrNull>( + 'disallowed_features', + ); + final Set parsed = raw == null + ? {} + : raw.map(DisallowedFeature.parse).toSet(); + return (features: parsed, hasFeatures: raw != null); + } + + Set _parseAssets(JsonMap data) { + final List? raw = data.valueOrNull>( + 'disallowed_assets', + ); + if (raw == null) return const {}; + + final Set out = {}; + for (final symbol in raw) { + try { + final assets = _sdk.assets.findAssetsByConfigId(symbol); + out.addAll(assets.map((a) => a.id)); + } catch (e, s) { + _log.warning('Failed to resolve asset "$symbol"', e, s); + } } + return out; } void dispose() { - _httpClient.close(); + _apiProvider.dispose(); } } diff --git a/lib/bloc/trading_status/trading_status_service.dart b/lib/bloc/trading_status/trading_status_service.dart new file mode 100644 index 0000000000..57f6d51aa8 --- /dev/null +++ b/lib/bloc/trading_status/trading_status_service.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/trading_status/app_geo_status.dart'; +import 'package:web_dex/bloc/trading_status/disallowed_feature.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_repository.dart'; + +/// Service class that manages trading status state and provides cached access +/// to trading restrictions. This service watches the trading status stream +/// and maintains the current state for efficient lookups. +class TradingStatusService { + TradingStatusService(this._repository); + + final TradingStatusRepository _repository; + final Logger _log = Logger('TradingStatusService'); + + /// Current cached trading status + /// Starts with a restrictive state to prevent race conditions during app startup + /// + /// TODO: UX Improvement - For faster startup, consider starting with an + /// unrestricted state and only apply restrictions once the API responds. + /// This would show all assets initially and remove blocked ones when the + /// bouncer returns restrictions. Trade-off: Better UX vs. brief exposure of + /// potentially blocked assets during initial API call (~100-500ms). + AppGeoStatus _currentStatus = const AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + + /// Stream subscription for trading status updates + StreamSubscription? _statusSubscription; + + /// Stream controller for broadcasting status changes + final StreamController _statusController = + StreamController.broadcast(); + + /// Track whether initialize has been called + bool _isInitialized = false; + + /// Track whether we've received the initial status from the API + bool _hasInitialStatus = false; + + /// Completer to track when initial status is ready + final Completer _initialStatusCompleter = Completer(); + + /// Future that completes when the initial status has been received + Future get initialStatusReady => _initialStatusCompleter.future; + + /// Stream of trading status updates + Stream get statusStream => _statusController.stream; + + /// Current trading status (cached) + AppGeoStatus get currentStatus { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus; + } + + /// Whether trading is currently enabled + bool get isTradingEnabled { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus.tradingEnabled; + } + + /// Set of currently blocked asset IDs + Set get blockedAssets { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus.disallowedAssets; + } + + /// Initialize the service by starting to watch trading status + /// Must be called after constructing the service + Future initialize() async { + assert( + !_isInitialized, + 'TradingStatusService.initialize() can only be called once', + ); + _isInitialized = true; + _log.info('Initializing trading status service'); + + try { + final initialStatus = await _repository.fetchStatus(); + _updateStatus(initialStatus); + } catch (error, stackTrace) { + _log.severe( + 'Failed to fetch initial trading status, defaulting to blocked', + error, + stackTrace, + ); + _updateStatus( + const AppGeoStatus(disallowedFeatures: {DisallowedFeature.trading}), + ); + } + + _startWatching(); + } + + /// Start watching trading status updates from the repository + void _startWatching() { + _statusSubscription?.cancel(); + + _statusSubscription = _repository.watchTradingStatus().listen( + _updateStatus, + onError: (error, stackTrace) { + _log.severe('Error in trading status stream', error, stackTrace); + // On error, assume trading is disabled for safety + _updateStatus( + const AppGeoStatus(disallowedFeatures: {DisallowedFeature.trading}), + ); + }, + ); + } + + /// Update the current status and broadcast changes + void _updateStatus(AppGeoStatus newStatus) { + final previousStatus = _currentStatus; + _currentStatus = newStatus; + + // Mark that we've received the initial status + if (!_hasInitialStatus) { + _hasInitialStatus = true; + if (!_initialStatusCompleter.isCompleted) { + _initialStatusCompleter.complete(); + } + _log.info('Initial trading status received'); + } + + if (previousStatus.tradingEnabled != newStatus.tradingEnabled) { + _log.info( + 'Trading status changed: ' + '${newStatus.tradingEnabled ? 'enabled' : 'disabled'}', + ); + } + + if (previousStatus.disallowedAssets.length != + newStatus.disallowedAssets.length) { + _log.info( + 'Blocked assets count changed: ' + '${previousStatus.disallowedAssets.length} -> ' + '${newStatus.disallowedAssets.length}', + ); + } + + _statusController.add(newStatus); + } + + /// Check if a specific asset is currently blocked + bool isAssetBlocked(AssetId assetId) { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + return _currentStatus.isAssetBlocked(assetId); + } + + /// Filter a list of assets to remove blocked ones + List filterAllowedAssets(List assets) { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + if (_currentStatus.tradingEnabled && + _currentStatus.disallowedAssets.isEmpty) { + return assets; + } + + return assets.where((asset) => !isAssetBlocked(asset.id)).toList(); + } + + /// Filter a map of assets to remove blocked ones + Map filterAllowedAssetsMap( + Map assetsMap, + AssetId Function(T) getAssetId, + ) { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + if (_currentStatus.tradingEnabled && + _currentStatus.disallowedAssets.isEmpty) { + return assetsMap; // No filtering needed + } + + return Map.fromEntries( + assetsMap.entries.where( + (entry) => !isAssetBlocked(getAssetId(entry.value)), + ), + ); + } + + /// Immediately refresh the trading status by fetching from the repository + /// Returns the fresh status and updates the cached status + Future refreshStatus({bool? forceFail}) async { + assert( + _isInitialized, + 'TradingStatusService must be initialized before use. Call initialize() first.', + ); + + _log.info('Refreshing trading status immediately'); + + try { + final freshStatus = await _repository.fetchStatus(forceFail: forceFail); + _updateStatus(freshStatus); + return freshStatus; + } catch (error, stackTrace) { + _log.severe('Error refreshing trading status', error, stackTrace); + // On error, assume trading is disabled for safety + const errorStatus = AppGeoStatus( + disallowedFeatures: {DisallowedFeature.trading}, + ); + _updateStatus(errorStatus); + rethrow; + } + } + + void dispose() { + _log.info('Disposing trading status service'); + _statusSubscription?.cancel(); + _statusController.close(); + } +} diff --git a/lib/bloc/trading_status/trading_status_state.dart b/lib/bloc/trading_status/trading_status_state.dart index 03a85be8a1..8595b3ef03 100644 --- a/lib/bloc/trading_status/trading_status_state.dart +++ b/lib/bloc/trading_status/trading_status_state.dart @@ -2,17 +2,56 @@ part of 'trading_status_bloc.dart'; abstract class TradingStatusState extends Equatable { @override - List get props => []; + List get props => [isEnabled]; - bool get isEnabled => this is TradingEnabled; + bool get isEnabled => + this is TradingStatusLoadSuccess && + !(this as TradingStatusLoadSuccess).disallowedFeatures.contains( + DisallowedFeature.trading, + ); } class TradingStatusInitial extends TradingStatusState {} class TradingStatusLoadInProgress extends TradingStatusState {} -class TradingEnabled extends TradingStatusState {} +class TradingStatusLoadSuccess extends TradingStatusState { + TradingStatusLoadSuccess({ + Set? disallowedAssets, + Set? disallowedFeatures, + }) : disallowedAssets = disallowedAssets ?? const {}, + disallowedFeatures = disallowedFeatures ?? const {}; -class TradingDisabled extends TradingStatusState {} + final Set disallowedAssets; + final Set disallowedFeatures; + + @override + bool get isEnabled => !disallowedFeatures.contains(DisallowedFeature.trading); + + @override + List get props => [disallowedAssets, disallowedFeatures]; +} class TradingStatusLoadFailure extends TradingStatusState {} + +extension TradingStatusStateX on TradingStatusState { + Set get disallowedAssetIds => this is TradingStatusLoadSuccess + ? (this as TradingStatusLoadSuccess).disallowedAssets + : const {}; + + bool isAssetBlocked(AssetId? asset) { + if (asset == null) return true; + if (this is! TradingStatusLoadSuccess) return true; + return (this as TradingStatusLoadSuccess).disallowedAssets.contains(asset); + } + + bool canTradeAssets(Iterable assets) { + if (!isEnabled) return false; + // Filter out nulls - only check assets that are actually selected + final nonNullAssets = assets.whereType(); + for (final asset in nonNullAssets) { + if (isAssetBlocked(asset)) return false; + } + return true; + } +} diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 08380eabd1..dba0eb8329 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -786,6 +786,7 @@ abstract class LocaleKeys { static const trend7d = 'trend7d'; static const tradingDisabledTooltip = 'tradingDisabledTooltip'; static const tradingDisabled = 'tradingDisabled'; + static const includeBlockedAssets = 'includeBlockedAssets'; static const unbanPubkeysResults = 'unbanPubkeysResults'; static const unbannedPubkeys = 'unbannedPubkeys'; static const stillBannedPubkeys = 'stillBannedPubkeys'; diff --git a/lib/main.dart b/lib/main.dart index 4f7902a1f9..8e8a22f76d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,8 @@ import 'package:web_dex/bloc/cex_market_data/cex_market_data.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_repository.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_service.dart'; import 'package:web_dex/blocs/wallets_repository.dart'; import 'package:web_dex/mm2/mm2.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; @@ -68,7 +70,15 @@ Future main() async { sparklineRepository, ); - final coinsRepo = CoinsRepo(kdfSdk: komodoDefiSdk, mm2: mm2); + final tradingStatusRepository = TradingStatusRepository(komodoDefiSdk); + final tradingStatusService = TradingStatusService(tradingStatusRepository); + await tradingStatusService.initialize(); + + final coinsRepo = CoinsRepo( + kdfSdk: komodoDefiSdk, + mm2: mm2, + tradingStatusService: tradingStatusService, + ); final walletsRepository = WalletsRepository( komodoDefiSdk, mm2Api, @@ -89,6 +99,8 @@ Future main() async { RepositoryProvider.value(value: coinsRepo), RepositoryProvider.value(value: walletsRepository), RepositoryProvider.value(value: sparklineRepository), + RepositoryProvider.value(value: tradingStatusRepository), + RepositoryProvider.value(value: tradingStatusService), ], child: const MyApp(), ), @@ -142,6 +154,9 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { final komodoDefiSdk = RepositoryProvider.of(context); final walletsRepository = RepositoryProvider.of(context); + final tradingStatusService = RepositoryProvider.of( + context, + ); final sensitivityController = ScreenshotSensitivityController(); return MultiBlocProvider( @@ -152,6 +167,7 @@ class MyApp extends StatelessWidget { komodoDefiSdk, walletsRepository, SettingsRepository(), + tradingStatusService, ); bloc.add(const AuthLifecycleCheckRequested()); return bloc; diff --git a/lib/shared/constants.dart b/lib/shared/constants.dart index 2c3b80d3fe..d6316d3b73 100644 --- a/lib/shared/constants.dart +++ b/lib/shared/constants.dart @@ -59,6 +59,6 @@ const String moralisProxyUrl = 'https://moralis-proxy.komodo.earth'; const String nftAntiSpamUrl = 'https://nft.antispam.dragonhound.info'; const String geoBlockerApiUrl = - 'https://komodo-wallet-bouncer.komodoplatform.com'; + 'https://komodo-wallet-bouncer.komodoplatform.com/v1/'; const String tradingBlacklistUrl = 'https://defi-stats.komodo.earth/api/v3/utils/blacklist'; diff --git a/lib/shared/ui/clock_warning_banner.dart b/lib/shared/ui/clock_warning_banner.dart index 16f0f4fcd4..1cbc567cbd 100644 --- a/lib/shared/ui/clock_warning_banner.dart +++ b/lib/shared/ui/clock_warning_banner.dart @@ -12,39 +12,37 @@ class ClockWarningBanner extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, systemHealthState) { - final tradingEnabled = - context.watch().state is TradingEnabled; + final tradingEnabled = context + .watch() + .state + .isEnabled; if (systemHealthState is SystemHealthLoadSuccess && !systemHealthState.isValid && tradingEnabled) { - return _buildWarningBanner(); + return Container( + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.warning, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text( + LocaleKeys.systemTimeWarning.tr(), + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ); } return const SizedBox.shrink(); }, ); } - - Widget _buildWarningBanner() { - return Container( - padding: const EdgeInsets.all(10), - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.warning, color: Colors.white), - const SizedBox(width: 8), - Expanded( - child: Text( - LocaleKeys.systemTimeWarning.tr(), - style: const TextStyle(color: Colors.white), - ), - ), - ], - ), - ); - } } diff --git a/lib/shared/widgets/logout_popup.dart b/lib/shared/widgets/logout_popup.dart index 32227ba5ff..fff7e2bdf0 100644 --- a/lib/shared/widgets/logout_popup.dart +++ b/lib/shared/widgets/logout_popup.dart @@ -39,7 +39,7 @@ class LogOutPopup extends StatelessWidget { if (currentWallet?.config.type == WalletType.iguana || currentWallet?.config.type == WalletType.hdwallet) SelectableText( - context.watch().state is! TradingEnabled + !context.watch().state.isEnabled ? LocaleKeys.logoutPopupDescriptionWalletOnly.tr() : LocaleKeys.logoutPopupDescription.tr(), style: const TextStyle( diff --git a/lib/views/bridge/bridge_confirmation.dart b/lib/views/bridge/bridge_confirmation.dart index e34d4a9e01..f8b662aa01 100644 --- a/lib/views/bridge/bridge_confirmation.dart +++ b/lib/views/bridge/bridge_confirmation.dart @@ -98,7 +98,7 @@ class _BridgeOrderConfirmationState extends State { const BridgeTotalFees(), const SizedBox(height: 24), const _ErrorGroup(), - _ButtonsRow(onCancel, startSwap), + _ButtonsRow(onCancel, startSwap, confirmDto), ], ), ), @@ -358,10 +358,11 @@ class _ErrorGroup extends StatelessWidget { } class _ButtonsRow extends StatelessWidget { - const _ButtonsRow(this.onCancel, this.startSwap); + const _ButtonsRow(this.onCancel, this.startSwap, this.dto); final void Function()? onCancel; final void Function()? startSwap; + final _ConfirmDTO dto; @override Widget build(BuildContext context) { @@ -370,7 +371,7 @@ class _ButtonsRow extends StatelessWidget { children: [ _BackButton(onCancel), const SizedBox(width: 23), - _ConfirmButton(startSwap), + _ConfirmButton(startSwap, dto), ], ), ); @@ -403,14 +404,18 @@ class _BackButton extends StatelessWidget { } class _ConfirmButton extends StatelessWidget { - const _ConfirmButton(this.onPressed); + const _ConfirmButton(this.onPressed, this.dto); final void Function()? onPressed; + final _ConfirmDTO dto; @override Widget build(BuildContext context) { final tradingStatusState = context.watch().state; - final tradingEnabled = tradingStatusState.isEnabled; + final tradingEnabled = tradingStatusState.canTradeAssets([ + dto.sellCoin.id, + dto.buyCoin.id, + ]); return Flexible( child: BlocSelector( diff --git a/lib/views/bridge/bridge_exchange_form.dart b/lib/views/bridge/bridge_exchange_form.dart index 91505e6dda..2334c43ee1 100644 --- a/lib/views/bridge/bridge_exchange_form.dart +++ b/lib/views/bridge/bridge_exchange_form.dart @@ -8,6 +8,7 @@ import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; import 'package:web_dex/bloc/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -55,15 +56,9 @@ class _BridgeExchangeFormState extends State { children: [ BridgeTickerSelector(), SizedBox(height: 30), - BridgeGroup( - header: SourceProtocolHeader(), - child: SourceProtocol(), - ), + BridgeGroup(header: SourceProtocolHeader(), child: SourceProtocol()), SizedBox(height: 19), - BridgeGroup( - header: TargetProtocolHeader(), - child: TargetProtocol(), - ), + BridgeGroup(header: TargetProtocolHeader(), child: TargetProtocol()), SizedBox(height: 12), BridgeFormErrorList(), SizedBox(height: 12), @@ -117,18 +112,27 @@ class _ExchangeButton extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, systemHealthState) { - // Determine if system clock is valid - final isSystemClockValid = systemHealthState is SystemHealthLoadSuccess && - systemHealthState.isValid; + builder: (context, systemHealthState) { + final isSystemClockValid = + systemHealthState is SystemHealthLoadSuccess && + systemHealthState.isValid; - final tradingStatusState = context.watch().state; - final tradingEnabled = tradingStatusState.isEnabled; + final coinsRepo = RepositoryProvider.of(context); - return BlocSelector( - selector: (state) => state.inProgress, - builder: (context, inProgress) { + return BlocBuilder( + builder: (context, bridgeState) { + final tradingStatusState = context.watch().state; + final targetCoin = bridgeState.bestOrder == null + ? null + : coinsRepo.getCoin(bridgeState.bestOrder!.coin); + final tradingEnabled = tradingStatusState.canTradeAssets([ + bridgeState.sellCoin?.id, + targetCoin?.id, + ]); + + final inProgress = bridgeState.inProgress; final isDisabled = inProgress || !isSystemClockValid; + return SizedBox( width: theme.custom.dexFormWidth, child: ConnectWalletWrapper( @@ -151,8 +155,10 @@ class _ExchangeButton extends StatelessWidget { ), ), ); - }); - }); + }, + ); + }, + ); } void _onPressed(BuildContext context) { diff --git a/lib/views/bridge/view/table/bridge_source_protocols_table.dart b/lib/views/bridge/view/table/bridge_source_protocols_table.dart index eebb8d8176..3712b88188 100644 --- a/lib/views/bridge/view/table/bridge_source_protocols_table.dart +++ b/lib/views/bridge/view/table/bridge_source_protocols_table.dart @@ -11,6 +11,7 @@ import 'package:web_dex/views/bridge/bridge_group.dart'; import 'package:web_dex/views/bridge/view/table/bridge_nothing_found.dart'; import 'package:web_dex/views/bridge/view/table/bridge_protocol_table_item.dart'; import 'package:web_dex/views/bridge/view/table/bridge_table_column_heads.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; class BridgeSourceProtocolsTable extends StatefulWidget { const BridgeSourceProtocolsTable({ @@ -82,7 +83,11 @@ class _SourceProtocolItems extends StatelessWidget { @override Widget build(BuildContext context) { - if (coins.isEmpty) return BridgeNothingFound(); + final tradingState = context.watch().state; + final filteredCoins = coins + .where((coin) => tradingState.canTradeAssets([coin.id])) + .toList(); + if (filteredCoins.isEmpty) return BridgeNothingFound(); final scrollController = ScrollController(); return Column( @@ -99,9 +104,9 @@ class _SourceProtocolItems extends StatelessWidget { controller: scrollController, padding: EdgeInsets.zero, shrinkWrap: true, - itemCount: coins.length, + itemCount: filteredCoins.length, itemBuilder: (BuildContext context, int index) { - final Coin coin = coins[index]; + final Coin coin = filteredCoins[index]; return BridgeProtocolTableItem( index: index, diff --git a/lib/views/bridge/view/table/bridge_target_protocols_table.dart b/lib/views/bridge/view/table/bridge_target_protocols_table.dart index 0505c5f8da..ec0d461b68 100644 --- a/lib/views/bridge/view/table/bridge_target_protocols_table.dart +++ b/lib/views/bridge/view/table/bridge_target_protocols_table.dart @@ -16,6 +16,7 @@ import 'package:web_dex/views/bridge/bridge_group.dart'; import 'package:web_dex/views/bridge/view/table/bridge_nothing_found.dart'; import 'package:web_dex/views/bridge/view/table/bridge_protocol_table_order_item.dart'; import 'package:web_dex/views/bridge/view/table/bridge_table_column_heads.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; class BridgeTargetProtocolsTable extends StatefulWidget { const BridgeTargetProtocolsTable({ @@ -114,6 +115,15 @@ class _TargetProtocolItems extends StatelessWidget { final scrollController = ScrollController(); final coinsRepository = RepositoryProvider.of(context); + final tradingState = context.watch().state; + final filteredTargets = targetsList.where((order) { + final Coin? coin = coinsRepository.getCoin(order.coin); + if (coin == null) return false; + return tradingState.canTradeAssets([sellCoin.id, coin.id]); + }).toList(); + + if (filteredTargets.isEmpty) return BridgeNothingFound(); + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -128,7 +138,7 @@ class _TargetProtocolItems extends StatelessWidget { controller: scrollController, shrinkWrap: true, itemBuilder: (BuildContext context, int index) { - final BestOrder order = targetsList[index]; + final BestOrder order = filteredTargets[index]; final Coin coin = coinsRepository.getCoin(order.coin)!; return BridgeProtocolTableOrderItem( @@ -138,7 +148,7 @@ class _TargetProtocolItems extends StatelessWidget { onSelect: () => onSelect(order), ); }, - itemCount: targetsList.length, + itemCount: filteredTargets.length, ), ), ), @@ -173,11 +183,12 @@ class _TargetProtocolErrorMessage extends StatelessWidget { const Icon(Icons.warning_amber, size: 14, color: Colors.orange), const SizedBox(width: 4), Flexible( - child: SelectableText( - error.message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - )), + child: SelectableText( + error.message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), const SizedBox(height: 4), UiSimpleButton( onPressed: onRetry, @@ -185,7 +196,7 @@ class _TargetProtocolErrorMessage extends StatelessWidget { LocaleKeys.retryButtonText.tr(), style: Theme.of(context).textTheme.bodySmall, ), - ) + ), ], ), ], diff --git a/lib/views/bridge/view/table/bridge_tickers_list.dart b/lib/views/bridge/view/table/bridge_tickers_list.dart index 7ae3e0a1f9..1e56186c2d 100644 --- a/lib/views/bridge/view/table/bridge_tickers_list.dart +++ b/lib/views/bridge/view/table/bridge_tickers_list.dart @@ -15,12 +15,10 @@ import 'package:web_dex/shared/ui/ui_flat_button.dart'; import 'package:web_dex/views/bridge/bridge_ticker_selector.dart'; import 'package:web_dex/views/bridge/bridge_tickers_list_item.dart'; import 'package:web_dex/views/dex/simple/form/tables/nothing_found.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; class BridgeTickersList extends StatefulWidget { - const BridgeTickersList({ - required this.onSelect, - Key? key, - }) : super(key: key); + const BridgeTickersList({required this.onSelect, Key? key}) : super(key: key); final Function(Coin) onSelect; @@ -53,7 +51,7 @@ class _BridgeTickersListState extends State { spreadRadius: 0, blurRadius: 4, offset: const Offset(0, 4), - ) + ), ], ), child: Column( @@ -86,9 +84,9 @@ class _BridgeTickersListState extends State { UiFlatButton( text: LocaleKeys.close.tr(), height: 40, - onPressed: () => context - .read() - .add(const BridgeShowTickerDropdown(false)), + onPressed: () => context.read().add( + const BridgeShowTickerDropdown(false), + ), ), ], ), @@ -104,12 +102,20 @@ class _BridgeTickersListState extends State { builder: (context, tickers) { if (tickers == null) return const UiSpinnerList(); - final Coins coinsList = - tickers.entries.fold([], (previousValue, element) { + final tradingState = context.watch().state; + + var coinsList = tickers.entries.fold([], ( + previousValue, + element, + ) { previousValue.add(element.value.first); return previousValue; }); + coinsList = coinsList + .where((coin) => tradingState.canTradeAssets([coin.id])) + .toList(); + if (_searchTerm != null && _searchTerm!.isNotEmpty) { final String searchTerm = _searchTerm!.toLowerCase(); coinsList.removeWhere((t) { diff --git a/lib/views/common/main_menu/main_menu_bar_mobile.dart b/lib/views/common/main_menu/main_menu_bar_mobile.dart index 516ab5da8c..1cb66a7110 100644 --- a/lib/views/common/main_menu/main_menu_bar_mobile.dart +++ b/lib/views/common/main_menu/main_menu_bar_mobile.dart @@ -21,8 +21,10 @@ class MainMenuBarMobile extends StatelessWidget { return BlocBuilder( builder: (context, state) { final bool isMMBotEnabled = state.mmBotSettings.isMMBotEnabled; - final bool tradingEnabled = - context.watch().state is TradingEnabled; + final bool tradingEnabled = context + .watch() + .state + .isEnabled; return DecoratedBox( decoration: BoxDecoration( color: theme.currentGlobal.cardColor, diff --git a/lib/views/common/main_menu/main_menu_desktop.dart b/lib/views/common/main_menu/main_menu_desktop.dart index b68cf0b8d4..4ae6450582 100644 --- a/lib/views/common/main_menu/main_menu_desktop.dart +++ b/lib/views/common/main_menu/main_menu_desktop.dart @@ -26,8 +26,9 @@ class MainMenuDesktop extends StatefulWidget { class _MainMenuDesktopState extends State { @override Widget build(BuildContext context) { - final isAuthenticated = context - .select((AuthBloc bloc) => bloc.state.mode == AuthorizeMode.logIn); + final isAuthenticated = context.select( + (AuthBloc bloc) => bloc.state.mode == AuthorizeMode.logIn, + ); return BlocBuilder( builder: (context, state) { @@ -36,8 +37,10 @@ class _MainMenuDesktopState extends State { final bool isDarkTheme = settingsState.themeMode == ThemeMode.dark; final bool isMMBotEnabled = settingsState.mmBotSettings.isMMBotEnabled; - final bool tradingEnabled = - context.watch().state is TradingEnabled; + final bool tradingEnabled = context + .watch() + .state + .isEnabled; final SettingsBloc settings = context.read(); final currentWallet = state.currentUser?.wallet; @@ -76,16 +79,18 @@ class _MainMenuDesktopState extends State { key: const Key('main-menu-wallet'), menu: MainMenuValue.wallet, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.wallet), + isSelected: _checkSelectedItem( + MainMenuValue.wallet, + ), ), DesktopMenuDesktopItem( key: const Key('main-menu-fiat'), enabled: currentWallet?.isHW != true, menu: MainMenuValue.fiat, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.fiat), + isSelected: _checkSelectedItem( + MainMenuValue.fiat, + ), ), Tooltip( message: tradingEnabled @@ -96,8 +101,9 @@ class _MainMenuDesktopState extends State { enabled: currentWallet?.isHW != true, menu: MainMenuValue.dex, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.dex), + isSelected: _checkSelectedItem( + MainMenuValue.dex, + ), ), ), Tooltip( @@ -109,8 +115,9 @@ class _MainMenuDesktopState extends State { enabled: currentWallet?.isHW != true, menu: MainMenuValue.bridge, onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.bridge), + isSelected: _checkSelectedItem( + MainMenuValue.bridge, + ), ), ), if (isMMBotEnabled && isAuthenticated) @@ -124,16 +131,17 @@ class _MainMenuDesktopState extends State { menu: MainMenuValue.marketMakerBot, onTap: onTapItem, isSelected: _checkSelectedItem( - MainMenuValue.marketMakerBot), + MainMenuValue.marketMakerBot, + ), ), ), DesktopMenuDesktopItem( - key: const Key('main-menu-nft'), - enabled: currentWallet?.isHW != true, - menu: MainMenuValue.nft, - onTap: onTapItem, - isSelected: - _checkSelectedItem(MainMenuValue.nft)), + key: const Key('main-menu-nft'), + enabled: currentWallet?.isHW != true, + menu: MainMenuValue.nft, + onTap: onTapItem, + isSelected: _checkSelectedItem(MainMenuValue.nft), + ), const Spacer(), Divider(thickness: 1), DesktopMenuDesktopItem( @@ -142,8 +150,9 @@ class _MainMenuDesktopState extends State { onTap: onTapItem, needAttention: currentWallet?.config.hasBackup == false, - isSelected: - _checkSelectedItem(MainMenuValue.settings), + isSelected: _checkSelectedItem( + MainMenuValue.settings, + ), ), ], ), @@ -156,31 +165,33 @@ class _MainMenuDesktopState extends State { padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), child: Theme( data: isDarkTheme ? newThemeDark : newThemeLight, - child: Builder(builder: (context) { - final ColorSchemeExtension colorScheme = - Theme.of(context) - .extension()!; - return DexThemeSwitcher( - isDarkTheme: isDarkTheme, - lightThemeTitle: LocaleKeys.lightMode.tr(), - darkThemeTitle: LocaleKeys.darkMode.tr(), - buttonKeyValue: 'theme-switcher', - onThemeModeChanged: (mode) { - settings.add( - ThemeModeChanged( - mode: isDarkTheme - ? ThemeMode.light - : ThemeMode.dark, - ), - ); - }, - switcherStyle: DexThemeSwitcherStyle( - textColor: colorScheme.primary, - thumbBgColor: colorScheme.surfContLow, - switcherBgColor: colorScheme.p10, - ), - ); - }), + child: Builder( + builder: (context) { + final ColorSchemeExtension colorScheme = Theme.of( + context, + ).extension()!; + return DexThemeSwitcher( + isDarkTheme: isDarkTheme, + lightThemeTitle: LocaleKeys.lightMode.tr(), + darkThemeTitle: LocaleKeys.darkMode.tr(), + buttonKeyValue: 'theme-switcher', + onThemeModeChanged: (mode) { + settings.add( + ThemeModeChanged( + mode: isDarkTheme + ? ThemeMode.light + : ThemeMode.dark, + ), + ); + }, + switcherStyle: DexThemeSwitcherStyle( + textColor: colorScheme.primary, + thumbBgColor: colorScheme.surfContLow, + switcherBgColor: colorScheme.p10, + ), + ); + }, + ), ), ), ], diff --git a/lib/views/dex/simple/confirm/maker_order_confirmation.dart b/lib/views/dex/simple/confirm/maker_order_confirmation.dart index b0733ff78c..48a8945192 100644 --- a/lib/views/dex/simple/confirm/maker_order_confirmation.dart +++ b/lib/views/dex/simple/confirm/maker_order_confirmation.dart @@ -28,8 +28,11 @@ import 'package:web_dex/views/dex/simple/form/maker/maker_form_exchange_rate.dar import 'package:web_dex/views/dex/simple/form/maker/maker_form_total_fees.dart'; class MakerOrderConfirmation extends StatefulWidget { - const MakerOrderConfirmation( - {super.key, required this.onCreateOrder, required this.onCancel}); + const MakerOrderConfirmation({ + super.key, + required this.onCreateOrder, + required this.onCancel, + }); final VoidCallback onCancel; final VoidCallback onCreateOrder; @@ -53,55 +56,60 @@ class _MakerOrderConfirmationState extends State { : const EdgeInsets.only(top: 9.0), constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), child: StreamBuilder( - initialData: makerFormBloc.preimage, - stream: makerFormBloc.outPreimage, - builder: (BuildContext context, - AsyncSnapshot preimageSnapshot) { - final preimage = preimageSnapshot.data; - if (preimage == null) return const UiSpinner(); - - final Coin? sellCoin = - coinsRepository.getCoin(preimage.request.base); - final Coin? buyCoin = coinsRepository.getCoin(preimage.request.rel); - final Rational? sellAmount = preimage.request.volume; - final Rational buyAmount = - (sellAmount ?? Rational.zero) * preimage.request.price; - - if (sellCoin == null || buyCoin == null) { - return Center(child: Text(LocaleKeys.dexErrorMessage.tr())); - } - - return SingleChildScrollView( - key: const Key('maker-order-conformation-scroll'), - controller: ScrollController(), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildTitle(), - const SizedBox(height: 37), - _buildReceive(buyCoin, buyAmount), - _buildFiatReceive( - sellCoin: sellCoin, - buyCoin: buyCoin, - sellAmount: sellAmount, - buyAmount: buyAmount, - ), - const SizedBox(height: 23), - _buildSend(sellCoin, sellAmount), - const SizedBox(height: 24), - const MakerFormExchangeRate(), - const SizedBox(height: 10), - const MakerFormTotalFees(), - const SizedBox(height: 24), - _buildError(), - Flexible( - child: _buildButtons(), - ) - ], - ), - ); - }), + initialData: makerFormBloc.preimage, + stream: makerFormBloc.outPreimage, + builder: + ( + BuildContext context, + AsyncSnapshot preimageSnapshot, + ) { + final preimage = preimageSnapshot.data; + if (preimage == null) return const UiSpinner(); + + final Coin? sellCoin = coinsRepository.getCoin( + preimage.request.base, + ); + final Coin? buyCoin = coinsRepository.getCoin( + preimage.request.rel, + ); + final Rational? sellAmount = preimage.request.volume; + final Rational buyAmount = + (sellAmount ?? Rational.zero) * preimage.request.price; + + if (sellCoin == null || buyCoin == null) { + return Center(child: Text(LocaleKeys.dexErrorMessage.tr())); + } + + return SingleChildScrollView( + key: const Key('maker-order-conformation-scroll'), + controller: ScrollController(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildTitle(), + const SizedBox(height: 37), + _buildReceive(buyCoin, buyAmount), + _buildFiatReceive( + sellCoin: sellCoin, + buyCoin: buyCoin, + sellAmount: sellAmount, + buyAmount: buyAmount, + ), + const SizedBox(height: 23), + _buildSend(sellCoin, sellAmount), + const SizedBox(height: 24), + const MakerFormExchangeRate(), + const SizedBox(height: 10), + const MakerFormTotalFees(), + const SizedBox(height: 24), + _buildError(), + Flexible(child: _buildButtons(sellCoin, buyCoin)), + ], + ), + ); + }, + ), ); } @@ -112,43 +120,43 @@ class _MakerOrderConfirmationState extends State { ); } - Widget _buildButtons() { + Widget _buildButtons(Coin sellCoin, Coin buyCoin) { return Row( children: [ - Flexible( - child: _buildBackButton(), - ), + Flexible(child: _buildBackButton()), const SizedBox(width: 23), - Flexible( - child: _buildConfirmButton(), - ), + Flexible(child: _buildConfirmButton(sellCoin, buyCoin)), ], ); } - Widget _buildConfirmButton() { + Widget _buildConfirmButton(Coin sellCoin, Coin buyCoin) { final tradingState = context.watch().state; - final bool tradingEnabled = tradingState.isEnabled; + final bool tradingEnabled = tradingState.canTradeAssets([ + sellCoin.id, + buyCoin.id, + ]); return Opacity( opacity: _inProgress ? 0.8 : 1, child: UiPrimaryButton( - key: const Key('make-order-confirm-button'), - prefix: _inProgress - ? Padding( - padding: const EdgeInsets.only(right: 8), - child: UiSpinner( - height: 10, - width: 10, - strokeWidth: 1, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ) - : null, - onPressed: _inProgress || !tradingEnabled ? null : _startSwap, - text: tradingEnabled - ? LocaleKeys.confirm.tr() - : LocaleKeys.tradingDisabled.tr()), + key: const Key('make-order-confirm-button'), + prefix: _inProgress + ? Padding( + padding: const EdgeInsets.only(right: 8), + child: UiSpinner( + height: 10, + width: 10, + strokeWidth: 1, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ) + : null, + onPressed: _inProgress || !tradingEnabled ? null : _startSwap, + text: tradingEnabled + ? LocaleKeys.confirm.tr() + : LocaleKeys.tradingDisabled.tr(), + ), ); } @@ -160,10 +168,9 @@ class _MakerOrderConfirmationState extends State { padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), child: Text( message, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.error), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ); } @@ -197,12 +204,14 @@ class _MakerOrderConfirmationState extends State { children: [ FiatAmount(coin: buyCoin, amount: buyAmount), if (percentage != null) - Text(' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: color, - fontWeight: FontWeight.w200, - )), + Text( + ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 11, + color: color, + fontWeight: FontWeight.w200, + ), + ), ], ); } @@ -210,8 +219,9 @@ class _MakerOrderConfirmationState extends State { Widget _buildFiatSend(Coin coin, Rational? amount) { if (amount == null) return const SizedBox(); return Container( - padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), - child: FiatAmount(coin: coin, amount: amount)); + padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), + child: FiatAmount(coin: coin, amount: amount), + ); } Widget _buildReceive(Coin coin, Rational? amount) { @@ -219,24 +229,22 @@ class _MakerOrderConfirmationState extends State { children: [ SelectableText( LocaleKeys.swapConfirmationYouReceive.tr(), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SelectableText('${formatDexAmt(amount)} ', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - )), + SelectableText( + '${formatDexAmt(amount)} ', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), SelectableText( Coin.normalizeAbbr(coin.abbr), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: theme.custom.balanceColor), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: theme.custom.balanceColor, + ), ), if (coin.mode == CoinMode.segwit) const Padding( @@ -251,10 +259,7 @@ class _MakerOrderConfirmationState extends State { Widget _buildSend(Coin coin, Rational? amount) { return Container( - padding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: theme.custom.subCardBackgroundColor, @@ -266,8 +271,8 @@ class _MakerOrderConfirmationState extends State { SelectableText( LocaleKeys.swapConfirmationYouSending.tr(), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + color: theme.custom.dexSubTitleColor, + ), ), const SizedBox(height: 10), Row( @@ -283,9 +288,9 @@ class _MakerOrderConfirmationState extends State { SelectableText( formatDexAmt(amount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), ), _buildFiatSend(coin, amount), ], @@ -319,13 +324,13 @@ class _MakerOrderConfirmationState extends State { final networks = '${makerFormBloc.sellCoin!.protocolType},${makerFormBloc.buyCoin!.protocolType}'; context.read().logEvent( - SwapInitiatedEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - networks: networks, - walletType: walletType, - ), - ); + SwapInitiatedEventData( + fromAsset: sellCoin, + toAsset: buyCoin, + networks: networks, + walletType: walletType, + ), + ); final int callStart = DateTime.now().millisecondsSinceEpoch; final TextError? error = await makerFormBloc.makeOrder(); @@ -343,28 +348,28 @@ class _MakerOrderConfirmationState extends State { if (error != null) { context.read().logEvent( - SwapFailedEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - failStage: 'order_submission', - walletType: walletType, - durationMs: durationMs, - ), - ); + SwapFailedEventData( + fromAsset: sellCoin, + toAsset: buyCoin, + failStage: 'order_submission', + walletType: walletType, + durationMs: durationMs, + ), + ); setState(() => _errorMessage = error.error); return; } context.read().logEvent( - SwapSucceededEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - amount: makerFormBloc.sellAmount!.toDouble(), - fee: 0, // Fee data not available - walletType: walletType, - durationMs: durationMs, - ), - ); + SwapSucceededEventData( + fromAsset: sellCoin, + toAsset: buyCoin, + amount: makerFormBloc.sellAmount!.toDouble(), + fee: 0, // Fee data not available + walletType: walletType, + durationMs: durationMs, + ), + ); makerFormBloc.clear(); widget.onCreateOrder(); } diff --git a/lib/views/dex/simple/confirm/taker_order_confirmation.dart b/lib/views/dex/simple/confirm/taker_order_confirmation.dart index 16d9a373f8..4321f4c02f 100644 --- a/lib/views/dex/simple/confirm/taker_order_confirmation.dart +++ b/lib/views/dex/simple/confirm/taker_order_confirmation.dart @@ -90,9 +90,7 @@ class _TakerOrderConfirmationState extends State { const TakerFormTotalFees(), const SizedBox(height: 24), _buildError(), - Flexible( - child: _buildButtons(), - ) + Flexible(child: _buildButtons(sellCoin, buyCoin)), ], ), ), @@ -116,23 +114,22 @@ class _TakerOrderConfirmationState extends State { ); } - Widget _buildButtons() { + Widget _buildButtons(Coin sellCoin, Coin buyCoin) { return Row( children: [ - Flexible( - child: _buildBackButton(), - ), + Flexible(child: _buildBackButton()), const SizedBox(width: 23), - Flexible( - child: _buildConfirmButton(), - ), + Flexible(child: _buildConfirmButton(sellCoin, buyCoin)), ], ); } - Widget _buildConfirmButton() { + Widget _buildConfirmButton(Coin sellCoin, Coin buyCoin) { final tradingStatusState = context.watch().state; - final bool tradingEnabled = tradingStatusState.isEnabled; + final bool tradingEnabled = tradingStatusState.canTradeAssets([ + sellCoin.id, + buyCoin.id, + ]); return BlocSelector( selector: (state) => state.inProgress, @@ -140,24 +137,25 @@ class _TakerOrderConfirmationState extends State { return Opacity( opacity: inProgress ? 0.8 : 1, child: UiPrimaryButton( - key: const Key('take-order-confirm-button'), - prefix: inProgress - ? Padding( - padding: const EdgeInsets.only(right: 8), - child: UiSpinner( - width: 10, - height: 10, - strokeWidth: 1, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ) - : null, - onPressed: inProgress || !tradingEnabled - ? null - : () => _startSwap(context), - text: tradingEnabled - ? LocaleKeys.confirm.tr() - : LocaleKeys.tradingDisabled.tr()), + key: const Key('take-order-confirm-button'), + prefix: inProgress + ? Padding( + padding: const EdgeInsets.only(right: 8), + child: UiSpinner( + width: 10, + height: 10, + strokeWidth: 1, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ) + : null, + onPressed: inProgress || !tradingEnabled + ? null + : () => _startSwap(context), + text: tradingEnabled + ? LocaleKeys.confirm.tr() + : LocaleKeys.tradingDisabled.tr(), + ), ); }, ); @@ -174,10 +172,9 @@ class _TakerOrderConfirmationState extends State { padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), child: Text( message, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.error), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ); }, @@ -213,12 +210,14 @@ class _TakerOrderConfirmationState extends State { children: [ FiatAmount(coin: buyCoin, amount: buyAmount), if (percentage != null) - Text(' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: color, - fontWeight: FontWeight.w200, - )), + Text( + ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 11, + color: color, + fontWeight: FontWeight.w200, + ), + ), ], ); } @@ -226,8 +225,9 @@ class _TakerOrderConfirmationState extends State { Widget _buildFiatSend(Coin coin, Rational? amount) { if (amount == null) return const SizedBox(); return Container( - padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), - child: FiatAmount(coin: coin, amount: amount)); + padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), + child: FiatAmount(coin: coin, amount: amount), + ); } Widget _buildReceive(Coin coin, Rational? amount) { @@ -235,24 +235,22 @@ class _TakerOrderConfirmationState extends State { children: [ SelectableText( LocaleKeys.swapConfirmationYouReceive.tr(), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SelectableText('${formatDexAmt(amount)} ', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - )), + SelectableText( + '${formatDexAmt(amount)} ', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), SelectableText( Coin.normalizeAbbr(coin.abbr), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: theme.custom.balanceColor), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: theme.custom.balanceColor, + ), ), if (coin.mode == CoinMode.segwit) const Padding( @@ -267,10 +265,7 @@ class _TakerOrderConfirmationState extends State { Widget _buildSend(Coin coin, Rational? amount) { return Container( - padding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: theme.custom.subCardBackgroundColor, @@ -282,8 +277,8 @@ class _TakerOrderConfirmationState extends State { SelectableText( LocaleKeys.swapConfirmationYouSending.tr(), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + color: theme.custom.dexSubTitleColor, + ), ), const SizedBox(height: 10), Row( @@ -299,9 +294,9 @@ class _TakerOrderConfirmationState extends State { SelectableText( formatDexAmt(amount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), ), _buildFiatSend(coin, amount), ], @@ -333,13 +328,13 @@ class _TakerOrderConfirmationState extends State { final networks = '${sellCoinObj.protocolType},${buyCoinObj?.protocolType ?? ''}'; context.read().logEvent( - SwapInitiatedEventData( - fromAsset: sellCoin, - toAsset: buyCoin, - networks: networks, - walletType: walletType, - ), - ); + SwapInitiatedEventData( + fromAsset: sellCoin, + toAsset: buyCoin, + networks: networks, + walletType: walletType, + ), + ); context.read().add(TakerStartSwap()); } @@ -350,8 +345,9 @@ class _TakerOrderConfirmationState extends State { context.read().add(TakerClear()); routingState.dexState.setDetailsAction(uuid); - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); await tradingEntitiesBloc.fetch(); } } diff --git a/lib/views/dex/simple/form/maker/maker_form_trade_button.dart b/lib/views/dex/simple/form/maker/maker_form_trade_button.dart index 44d1704bb2..df31afbc5e 100644 --- a/lib/views/dex/simple/form/maker/maker_form_trade_button.dart +++ b/lib/views/dex/simple/form/maker/maker_form_trade_button.dart @@ -8,6 +8,7 @@ import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/blocs/maker_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; class MakerFormTradeButton extends StatelessWidget { const MakerFormTradeButton({Key? key}) : super(key: key); @@ -15,59 +16,80 @@ class MakerFormTradeButton extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, systemHealthState) { - // Determine if system clock is valid - final bool isSystemClockValid = - systemHealthState is SystemHealthLoadSuccess && - systemHealthState.isValid; + builder: (context, systemHealthState) { + // Determine if system clock is valid + final bool isSystemClockValid = + systemHealthState is SystemHealthLoadSuccess && + systemHealthState.isValid; - final tradingState = context.watch().state; - final isTradingEnabled = tradingState.isEnabled; + final makerFormBloc = RepositoryProvider.of(context); + final authBloc = context.watch(); - final makerFormBloc = RepositoryProvider.of(context); - final authBloc = context.watch(); + return StreamBuilder( + initialData: makerFormBloc.sellCoin, + stream: makerFormBloc.outSellCoin, + builder: (context, sellSnapshot) { + return StreamBuilder( + initialData: makerFormBloc.buyCoin, + stream: makerFormBloc.outBuyCoin, + builder: (context, buySnapshot) { + final tradingState = context.watch().state; + final isTradingEnabled = tradingState.canTradeAssets([ + sellSnapshot.data?.id, + buySnapshot.data?.id, + ]); - return StreamBuilder( - initialData: makerFormBloc.inProgress, - stream: makerFormBloc.outInProgress, - builder: (context, snapshot) { - final bool inProgress = snapshot.data ?? false; - final bool disabled = inProgress || !isSystemClockValid; + return StreamBuilder( + initialData: makerFormBloc.inProgress, + stream: makerFormBloc.outInProgress, + builder: (context, snapshot) { + final bool inProgress = snapshot.data ?? false; + final bool disabled = inProgress || !isSystemClockValid; - return Opacity( - opacity: disabled ? 0.8 : 1, - child: UiPrimaryButton( - key: const Key('make-order-button'), - text: isTradingEnabled - ? LocaleKeys.makeOrder.tr() - : LocaleKeys.tradingDisabled.tr(), - prefix: inProgress - ? Padding( - padding: const EdgeInsets.only(right: 4), - child: UiSpinner( - width: 10, - height: 10, - strokeWidth: 1, - color: theme.custom.defaultGradientButtonTextColor, - ), - ) - : null, - onPressed: disabled || !isTradingEnabled - ? null - : () async { - while (!authBloc.state.isSignedIn) { - await Future.delayed( - const Duration(milliseconds: 300)); - } - final bool isValid = await makerFormBloc.validate(); - if (!isValid) return; + return Opacity( + opacity: disabled ? 0.8 : 1, + child: UiPrimaryButton( + key: const Key('make-order-button'), + text: isTradingEnabled + ? LocaleKeys.makeOrder.tr() + : LocaleKeys.tradingDisabled.tr(), + prefix: inProgress + ? Padding( + padding: const EdgeInsets.only(right: 4), + child: UiSpinner( + width: 10, + height: 10, + strokeWidth: 1, + color: theme + .custom + .defaultGradientButtonTextColor, + ), + ) + : null, + onPressed: disabled || !isTradingEnabled + ? null + : () async { + while (!authBloc.state.isSignedIn) { + await Future.delayed( + const Duration(milliseconds: 300), + ); + } + final bool isValid = await makerFormBloc + .validate(); + if (!isValid) return; - makerFormBloc.showConfirmation = true; - }, - height: 40, - ), + makerFormBloc.showConfirmation = true; + }, + height: 40, + ), + ); + }, + ); + }, ); - }); - }); + }, + ); + }, + ); } } diff --git a/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart b/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart index c44648c4ab..4b35c3402f 100644 --- a/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/views/dex/simple/form/tables/nothing_found.dart'; import 'package:web_dex/views/dex/simple/form/tables/orders_table/grouped_list_view.dart'; @@ -20,20 +21,30 @@ class CoinsTableContent extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final coins = prepareCoinsForTable( - context, - state.coins.values.toList(), - searchString, - testCoinsEnabled: context.read().state.testCoinsEnabled, - ); - if (coins.isEmpty) return const NothingFound(); + // FIX: Using BlocBuilder to listen to TradingStatusBloc changes + // This ensures the coin list is re-filtered when geo-blocking status changes. + // Following BLoC best practices: widgets should rebuild when dependent bloc states change. + return BlocBuilder( + builder: (context, tradingStatus) { + return BlocBuilder( + builder: (context, coinsState) { + final coins = prepareCoinsForTable( + context, + coinsState.coins.values.toList(), + searchString, + testCoinsEnabled: context + .read() + .state + .testCoinsEnabled, + ); + if (coins.isEmpty) return const NothingFound(); - return GroupedListView( - items: coins, - onSelect: onSelect, - maxHeight: maxHeight, + return GroupedListView( + items: coins, + onSelect: onSelect, + maxHeight: maxHeight, + ); + }, ); }, ); diff --git a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart index a1d321403f..f8a9b812ea 100644 --- a/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart +++ b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart @@ -6,6 +6,7 @@ import 'package:web_dex/bloc/settings/settings_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_event.dart'; import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; @@ -28,36 +29,46 @@ class OrdersTableContent extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.bestOrders, - builder: (context, bestOrders) { - if (bestOrders == null) { - return Container( - padding: const EdgeInsets.fromLTRB(0, 30, 0, 10), - alignment: const Alignment(0, 0), - child: const UiSpinner(), - ); - } + // FIX: Using BlocBuilder to listen to TradingStatusBloc changes + // This ensures the orders list is re-filtered when geo-blocking status changes. + // Following BLoC best practices: widgets should rebuild when dependent bloc states change. + return BlocBuilder( + builder: (context, tradingStatus) { + return BlocSelector( + selector: (state) => state.bestOrders, + builder: (context, bestOrders) { + if (bestOrders == null) { + return Container( + padding: const EdgeInsets.fromLTRB(0, 30, 0, 10), + alignment: const Alignment(0, 0), + child: const UiSpinner(), + ); + } - final BaseError? error = bestOrders.error; - if (error != null) return _ErrorMessage(error); + final BaseError? error = bestOrders.error; + if (error != null) return _ErrorMessage(error); - final Map> ordersMap = bestOrders.result!; - final AuthorizeMode mode = context.watch().state.mode; - final List orders = prepareOrdersForTable( - context, - ordersMap, - searchString, - mode, - testCoinsEnabled: context.read().state.testCoinsEnabled, - ); + final Map> ordersMap = bestOrders.result!; + final AuthorizeMode mode = context.watch().state.mode; + final List orders = prepareOrdersForTable( + context, + ordersMap, + searchString, + mode, + testCoinsEnabled: context + .read() + .state + .testCoinsEnabled, + ); - if (orders.isEmpty) return const NothingFound(); + if (orders.isEmpty) return const NothingFound(); - return GroupedListView( - items: orders, - onSelect: onSelect, - maxHeight: maxHeight, + return GroupedListView( + items: orders, + onSelect: onSelect, + maxHeight: maxHeight, + ); + }, ); }, ); @@ -83,11 +94,12 @@ class _ErrorMessage extends StatelessWidget { const Icon(Icons.warning_amber, size: 14, color: Colors.orange), const SizedBox(width: 4), Flexible( - child: SelectableText( - error.message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - )), + child: SelectableText( + error.message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), const SizedBox(height: 4), UiSimpleButton( child: Text( @@ -96,7 +108,7 @@ class _ErrorMessage extends StatelessWidget { ), onPressed: () => context.read().add(TakerUpdateBestOrders()), - ) + ), ], ), ], diff --git a/lib/views/dex/simple/form/tables/table_utils.dart b/lib/views/dex/simple/form/tables/table_utils.dart index ac11a02529..5b4878b672 100644 --- a/lib/views/dex/simple/form/tables/table_utils.dart +++ b/lib/views/dex/simple/form/tables/table_utils.dart @@ -9,6 +9,7 @@ import 'package:web_dex/model/authorize_mode.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_utils.dart'; import 'package:web_dex/shared/utils/balances_formatter.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; List prepareCoinsForTable( BuildContext context, @@ -20,6 +21,7 @@ List prepareCoinsForTable( coins = List.from(coins); if (!testCoinsEnabled) coins = removeTestCoins(coins); coins = removeWalletOnly(coins); + coins = removeDisallowedCoins(context, coins); coins = removeSuspended(coins, authBloc.state.isSignedIn); coins = sortByPriorityAndBalance(coins, GetIt.I()); coins = filterCoinsByPhrase(coins, searchString ?? '').toList(); @@ -48,6 +50,9 @@ List prepareOrdersForTable( removeWalletOnlyCoinOrders(sorted, context); if (sorted.isEmpty) return []; + removeDisallowedCoinOrders(sorted, context); + if (sorted.isEmpty) return []; + final String? filter = searchString?.toLowerCase(); if (filter == null || filter.isEmpty) { return sorted; @@ -63,8 +68,82 @@ List prepareOrdersForTable( return filtered; } +/// Filters out coins that are geo-blocked based on the current trading status. +/// +/// TECH DEBT / BLoC ANTI-PATTERN WARNING: +/// This function uses [context.read] to access [TradingStatusBloc] state. +/// According to BLoC best practices, [context.read] should NOT be used to +/// retrieve state within build methods because it doesn't establish a subscription +/// to state changes. +/// +/// IMPACT: When this function is called from a build method, the widget won't +/// automatically rebuild when [TradingStatusBloc] state changes (e.g., when +/// geo-blocking status updates). +/// +/// FIX APPLIED: All widgets calling this function now wrap their build methods +/// with [BlocBuilder] to ensure rebuilds when trading status changes. +/// +/// RECOMMENDED REFACTOR: +/// Following SOLID principles (Single Responsibility), filtering logic should be +/// moved into the respective Blocs rather than utility functions that access +/// other Blocs' state. This would: +/// 1. Remove presentation layer's direct dependency on [TradingStatusBloc] +/// 2. Enable proper bloc-to-bloc communication through events +/// 3. Make state changes more predictable and testable +/// 4. Follow the unidirectional data flow pattern +List removeDisallowedCoins(BuildContext context, List coins) { + final tradingState = context.read().state; + if (!tradingState.isEnabled) return []; + return coins.where((coin) => tradingState.canTradeAssets([coin.id])).toList(); +} + +/// Filters out orders for coins that are geo-blocked based on the current trading status. +/// Modifies the [orders] list in-place. +/// +/// TECH DEBT / BLoC ANTI-PATTERN WARNING: +/// This function uses [context.read] to access [TradingStatusBloc] state. +/// According to BLoC best practices, [context.read] should NOT be used to +/// retrieve state within build methods because it doesn't establish a subscription +/// to state changes. +/// +/// IMPACT: When this function is called from a build method, the widget won't +/// automatically rebuild when [TradingStatusBloc] state changes (e.g., when +/// geo-blocking status updates). +/// +/// FIX APPLIED: All widgets calling this function now wrap their build methods +/// with [BlocBuilder] to ensure rebuilds when trading status changes. +/// +/// RECOMMENDED REFACTOR: +/// Following SOLID principles (Single Responsibility), filtering logic should be +/// moved into the respective Blocs rather than utility functions that access +/// other Blocs' state. This would: +/// 1. Remove presentation layer's direct dependency on [TradingStatusBloc] +/// 2. Enable proper bloc-to-bloc communication through events +/// 3. Make state changes more predictable and testable +/// 4. Follow the unidirectional data flow pattern +/// +/// ADDITIONAL TECH DEBT: +/// This function mutates the input list in-place, which is a side effect that +/// can make code harder to reason about and test. Consider returning a new +/// filtered list instead (similar to [removeDisallowedCoins]). +void removeDisallowedCoinOrders(List orders, BuildContext context) { + final tradingState = context.read().state; + if (!tradingState.isEnabled) { + orders.clear(); + return; + } + final coinsRepository = RepositoryProvider.of(context); + orders.removeWhere((order) { + final Coin? coin = coinsRepository.getCoin(order.coin); + if (coin == null) return true; + return !tradingState.canTradeAssets([coin.id]); + }); +} + List _sortBestOrders( - BuildContext context, Map> unsorted) { + BuildContext context, + Map> unsorted, +) { if (unsorted.isEmpty) return []; final coinsRepository = RepositoryProvider.of(context); diff --git a/lib/views/dex/simple/form/taker/taker_form_content.dart b/lib/views/dex/simple/form/taker/taker_form_content.dart index ff6d4f1de3..3b9da98bba 100644 --- a/lib/views/dex/simple/form/taker/taker_form_content.dart +++ b/lib/views/dex/simple/form/taker/taker_form_content.dart @@ -127,8 +127,16 @@ class TradeButton extends StatelessWidget { systemHealthState.isValid; final tradingStatusState = context.watch().state; - - final isTradingEnabled = tradingStatusState.isEnabled; + final takerState = context.watch().state; + final coinsRepo = RepositoryProvider.of(context); + final buyCoin = takerState.selectedOrder == null + ? null + : coinsRepo.getCoin(takerState.selectedOrder!.coin); + + final isTradingEnabled = tradingStatusState.canTradeAssets([ + takerState.sellCoin?.id, + buyCoin?.id, + ]); return BlocSelector( selector: (state) => state.inProgress, diff --git a/lib/views/main_layout/main_layout.dart b/lib/views/main_layout/main_layout.dart index 8161d54af5..a64f61f82a 100644 --- a/lib/views/main_layout/main_layout.dart +++ b/lib/views/main_layout/main_layout.dart @@ -54,7 +54,7 @@ class _MainLayoutState extends State { } if (!mounted) return; - final tradingEnabled = tradingStatusBloc.state is TradingEnabled; + final tradingEnabled = tradingStatusBloc.state.isEnabled; if (tradingEnabled && kShowTradingWarning && !await _hasAgreedNoTrading()) { diff --git a/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart b/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart index ddf22b7c39..47659ef945 100644 --- a/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart +++ b/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart @@ -5,16 +5,21 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; class AddMarketMakerBotTradeButton extends StatelessWidget { const AddMarketMakerBotTradeButton({ super.key, required this.onPressed, this.enabled = true, + this.sellCoin, + this.buyCoin, }); final bool enabled; final VoidCallback onPressed; + final Coin? sellCoin; + final Coin? buyCoin; @override Widget build(BuildContext context) { @@ -22,7 +27,10 @@ class AddMarketMakerBotTradeButton extends StatelessWidget { builder: (context, systemHealthState) { final tradingStatusBloc = context.watch(); - final bool tradingEnabled = tradingStatusBloc.state.isEnabled; + final bool tradingEnabled = tradingStatusBloc.state.canTradeAssets([ + sellCoin?.id, + buyCoin?.id, + ]); return Opacity( opacity: !enabled ? 0.8 : 1, diff --git a/lib/views/market_maker_bot/coin_selection_and_amount_input.dart b/lib/views/market_maker_bot/coin_selection_and_amount_input.dart index 756d15389a..b363b65451 100644 --- a/lib/views/market_maker_bot/coin_selection_and_amount_input.dart +++ b/lib/views/market_maker_bot/coin_selection_and_amount_input.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/shared/widgets/coin_item/coin_item_body.dart'; import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; @@ -39,6 +40,11 @@ class CoinSelectionAndAmountInput extends StatefulWidget { class _CoinSelectionAndAmountInputState extends State { + // TECH DEBT: This widget uses StatefulWidget with local state (_items) + // which is an anti-pattern when the data depends on Bloc state. + // Following BLoC best practices, this should be refactored to use BlocBuilder + // and compute the items in the build method based on bloc state changes. + // Currently, we work around this by wrapping the widget in BlocBuilder below. late List> _items; @override @@ -86,48 +92,58 @@ class _CoinSelectionAndAmountInputState @override Widget build(BuildContext context) { - Widget content = Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DexFormGroupHeader(title: widget.title), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.fromLTRB(15, 8, 0, 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - mainAxisSize: MainAxisSize.min, + // FIX: Using BlocBuilder to listen to TradingStatusBloc changes + // This ensures _prepareItems is called when geo-blocking status changes. + // Following BLoC best practices: widgets should rebuild when dependent bloc states change. + return BlocBuilder( + builder: (context, tradingStatus) { + // Rebuild items when trading status changes + _prepareItems(); + + Widget content = Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DexFormGroupHeader(title: widget.title), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(15, 8, 0, 12), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - widget.selectedCoin == null - ? AssetLogo.placeholder(isBlank: true) - : AssetLogo.ofId(widget.selectedCoin!.id), - const SizedBox(width: 9), - CoinNameAndProtocol(widget.selectedCoin, true), - const SizedBox(width: 9), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + widget.selectedCoin == null + ? AssetLogo.placeholder(isBlank: true) + : AssetLogo.ofId(widget.selectedCoin!.id), + const SizedBox(width: 9), + CoinNameAndProtocol(widget.selectedCoin, true), + const SizedBox(width: 9), + ], + ), + const SizedBox(width: 5), + Expanded(child: widget.trailing ?? const SizedBox.shrink()), ], ), - const SizedBox(width: 5), - Expanded(child: widget.trailing ?? const SizedBox.shrink()), - ], - ), - ), - ], - ); + ), + ], + ); - if (widget.useFrontPlate) { - content = FrontPlate(child: content); - } + if (widget.useFrontPlate) { + content = FrontPlate(child: content); + } - final coinsRepository = RepositoryProvider.of(context); - return CoinDropdown( - items: _items, - onItemSelected: (item) => - widget.onItemSelected?.call(coinsRepository.getCoin(item)), - child: content, + final coinsRepository = RepositoryProvider.of(context); + return CoinDropdown( + items: _items, + onItemSelected: (item) => + widget.onItemSelected?.call(coinsRepository.getCoin(item)), + child: content, + ); + }, ); } } diff --git a/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart b/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart index 67588caf10..54b6001324 100644 --- a/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart +++ b/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart @@ -40,9 +40,9 @@ class _MarketMakerBotConfirmationFormState extends State { @override void initState() { - context - .read() - .add(const MarketMakerConfirmationPreviewRequested()); + context.read().add( + const MarketMakerConfirmationPreviewRequested(), + ); super.initState(); } @@ -63,7 +63,8 @@ class _MarketMakerBotConfirmationFormState return const SizedBox(); } - final hasError = state.tradePreImageError != null || + final hasError = + state.tradePreImageError != null || state.status == MarketMakerTradeFormStatus.error; return SingleChildScrollView( @@ -75,15 +76,15 @@ class _MarketMakerBotConfirmationFormState children: [ SelectableText( LocaleKeys.mmBotFirstTradePreview.tr(), - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontSize: 16), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontSize: 16), ), const SizedBox(height: 37), ImportantNote( - text: LocaleKeys.mmBotFirstOrderVolume - .tr(args: [state.sellCoin.value?.abbr ?? '']), + text: LocaleKeys.mmBotFirstOrderVolume.tr( + args: [state.sellCoin.value?.abbr ?? ''], + ), ), const SizedBox(height: 10), SwapReceiveAmount( @@ -114,14 +115,18 @@ class _MarketMakerBotConfirmationFormState TotalFees(preimage: state.tradePreImage), const SizedBox(height: 24), SwapErrorMessage( - errorMessage: state.tradePreImageError - ?.text(state.sellCoin.value, state.buyCoin.value), + errorMessage: state.tradePreImageError?.text( + state.sellCoin.value, + state.buyCoin.value, + ), context: context, ), Flexible( child: SwapActionButtons( onCancel: widget.onCancel, onCreateOrder: hasError ? null : widget.onCreateOrder, + sellCoin: state.sellCoin.value, + buyCoin: state.buyCoin.value, ), ), ], @@ -138,24 +143,28 @@ class SwapActionButtons extends StatelessWidget { super.key, required this.onCancel, required this.onCreateOrder, + required this.sellCoin, + required this.buyCoin, }); final VoidCallback onCancel; final VoidCallback? onCreateOrder; + final Coin? sellCoin; + final Coin? buyCoin; @override Widget build(BuildContext context) { final tradingStatusBloc = context.watch(); - final bool tradingEnabled = tradingStatusBloc.state is TradingEnabled; + final bool tradingEnabled = tradingStatusBloc.state.canTradeAssets([ + sellCoin?.id, + buyCoin?.id, + ]); return Row( children: [ Flexible( - child: UiLightButton( - onPressed: onCancel, - text: LocaleKeys.back.tr(), - ), + child: UiLightButton(onPressed: onCancel, text: LocaleKeys.back.tr()), ), const SizedBox(width: 23), Flexible( @@ -191,10 +200,9 @@ class SwapErrorMessage extends StatelessWidget { padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), child: Text( message, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.error), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ); } @@ -215,10 +223,7 @@ class SwapSendAmount extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), color: theme.custom.subCardBackgroundColor, @@ -230,8 +235,8 @@ class SwapSendAmount extends StatelessWidget { SelectableText( LocaleKeys.swapConfirmationYouSending.tr(), style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + color: theme.custom.dexSubTitleColor, + ), ), const SizedBox(height: 10), Row( @@ -247,9 +252,9 @@ class SwapSendAmount extends StatelessWidget { SelectableText( formatDexAmt(amount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w500, - ), + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), ), SwapFiatSendAmount(coin: coin, amount: amount), ], @@ -305,10 +310,14 @@ class SwapFiatReceivedAmount extends StatelessWidget { Color? color = Theme.of(context).textTheme.bodyMedium?.color; double? percentage; - final double sellAmtFiat = - getFiatAmount(sellCoin, sellAmount ?? Rational.zero); - final double receiveAmtFiat = - getFiatAmount(buyCoin, buyAmount ?? Rational.zero); + final double sellAmtFiat = getFiatAmount( + sellCoin, + sellAmount ?? Rational.zero, + ); + final double receiveAmtFiat = getFiatAmount( + buyCoin, + buyAmount ?? Rational.zero, + ); if (sellAmtFiat < receiveAmtFiat) { color = theme.custom.increaseColor; @@ -328,10 +337,10 @@ class SwapFiatReceivedAmount extends StatelessWidget { Text( ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 11, - color: color, - fontWeight: FontWeight.w200, - ), + fontSize: 11, + color: color, + fontWeight: FontWeight.w200, + ), ), ], ); @@ -356,26 +365,22 @@ class SwapReceiveAmount extends StatelessWidget { children: [ SelectableText( LocaleKeys.swapConfirmationYouReceive.tr(), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: theme.custom.dexSubTitleColor, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: theme.custom.dexSubTitleColor), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SelectableText( '${formatDexAmt(amount)} ', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - ), + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), ), SelectableText( Coin.normalizeAbbr(coin.abbr), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: theme.custom.balanceColor), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: theme.custom.balanceColor, + ), ), if (coin.mode == CoinMode.segwit) const Padding( diff --git a/lib/views/market_maker_bot/market_maker_bot_form_content.dart b/lib/views/market_maker_bot/market_maker_bot_form_content.dart index 1e4792a016..b0fab020f5 100644 --- a/lib/views/market_maker_bot/market_maker_bot_form_content.dart +++ b/lib/views/market_maker_bot/market_maker_bot_form_content.dart @@ -154,6 +154,8 @@ class _MarketMakerBotFormContentState extends State { child: AddMarketMakerBotTradeButton( enabled: state.isValid, onPressed: _onMakeOrderPressed, + sellCoin: state.sellCoin.value, + buyCoin: state.buyCoin.value, ), ), ), diff --git a/lib/views/settings/widgets/general_settings/general_settings.dart b/lib/views/settings/widgets/general_settings/general_settings.dart index a13ccdf5db..9e807451e2 100644 --- a/lib/views/settings/widgets/general_settings/general_settings.dart +++ b/lib/views/settings/widgets/general_settings/general_settings.dart @@ -36,28 +36,18 @@ class GeneralSettings extends StatelessWidget { child: SettingsManageWeakPasswords(), ), const SizedBox(height: 25), - if (context.watch().state is TradingEnabled) + if (context.watch().state.isEnabled) const HiddenWithoutWallet( isHiddenForHw: true, child: SettingsManageTradingBot(), ), const SizedBox(height: 25), - const HiddenWithoutWallet( - child: SettingsDownloadLogs(), - ), + const HiddenWithoutWallet(child: SettingsDownloadLogs()), const SizedBox(height: 25), - const HiddenWithWallet( - child: SettingsResetActivatedCoins(), - ), + const HiddenWithWallet(child: SettingsResetActivatedCoins()), const SizedBox(height: 25), - const HiddenWithoutWallet( - isHiddenForHw: true, - child: ShowSwapData(), - ), - const HiddenWithoutWallet( - isHiddenForHw: true, - child: ImportSwaps(), - ), + const HiddenWithoutWallet(isHiddenForHw: true, child: ShowSwapData()), + const HiddenWithoutWallet(isHiddenForHw: true, child: ImportSwaps()), ], ); } diff --git a/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart b/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart index e20df95aa6..f70159f966 100644 --- a/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart +++ b/lib/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart @@ -41,88 +41,198 @@ class PrivateKeyShow extends StatelessWidget { /// [privateKeys] Map of asset IDs to their corresponding private keys. /// **Security Note**: This data should be handled with extreme care and /// cleared from memory as soon as possible. - const PrivateKeyShow({required this.privateKeys}); + const PrivateKeyShow({ + required this.privateKeys, + this.blockedAssetIds = const {}, + }); /// Private keys organized by asset ID. /// /// **Security Note**: This data is intentionally passed directly to the UI /// rather than stored in BLoC state to minimize memory exposure and lifetime. final Map> privateKeys; + final Set blockedAssetIds; @override Widget build(BuildContext context) { - return ScreenshotSensitive(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - if (!isMobile) - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: SeedBackButton(() { - // Track analytics based on whether keys were copied - final wasBackupCompleted = context - .read() - .state - .arePrivateKeysSaved; + return ScreenshotSensitive( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (!isMobile) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: SeedBackButton(() { + // Track analytics based on whether keys were copied + final wasBackupCompleted = context + .read() + .state + .arePrivateKeysSaved; - final walletType = - context - .read() - .state - .currentUser - ?.wallet - .config - .type - .name ?? - ''; + final walletType = + context + .read() + .state + .currentUser + ?.wallet + .config + .type + .name ?? + ''; - if (wasBackupCompleted) { - // User copied keys, so track as completed backup - context.read().add( - AnalyticsBackupCompletedEvent( - backupTime: 0, - method: 'private_key_export', - walletType: walletType, - ), - ); - } else { - // User didn't copy keys, so track as skipped - context.read().add( - AnalyticsBackupSkippedEvent( - stageSkipped: 'private_key_show', - walletType: walletType, - ), - ); - } + if (wasBackupCompleted) { + // User copied keys, so track as completed backup + context.read().add( + AnalyticsBackupCompletedEvent( + backupTime: 0, + method: 'private_key_export', + walletType: walletType, + ), + ); + } else { + // User didn't copy keys, so track as skipped + context.read().add( + AnalyticsBackupSkippedEvent( + stageSkipped: 'private_key_show', + walletType: walletType, + ), + ); + } - context.read().add(const ResetEvent()); - }), + context.read().add(const ResetEvent()); + }), + ), + + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _TitleRow(), + const SizedBox(height: 16), + const _SecurityWarning(), + const SizedBox(height: 16), + const _CopyWarning(), + const SizedBox(height: 16), + PrivateKeyExportSection( + privateKeys: privateKeys, + blockedAssetIds: blockedAssetIds, + ), + ], ), + ], + ), + ); + } +} - Column( - crossAxisAlignment: CrossAxisAlignment.start, +class PrivateKeyExportSection extends StatefulWidget { + const PrivateKeyExportSection({ + super.key, + required this.privateKeys, + required this.blockedAssetIds, + }); + + final Map> privateKeys; + final Set blockedAssetIds; + + @override + State createState() => + _PrivateKeyExportSectionState(); +} + +class _PrivateKeyExportSectionState extends State { + bool _includeBlockedAssets = false; + + @override + void initState() { + super.initState(); + _includeBlockedAssets = !_hasBlockedAssetsInKeys(); + } + + bool _hasBlockedAssetsInKeys() { + if (widget.blockedAssetIds.isEmpty || widget.privateKeys.isEmpty) { + return false; + } + for (final assetId in widget.privateKeys.keys) { + if (widget.blockedAssetIds.contains(assetId)) return true; + } + return false; + } + + Map> _filteredPrivateKeys() { + if (_includeBlockedAssets || widget.blockedAssetIds.isEmpty) { + return widget.privateKeys; + } + final entries = widget.privateKeys.entries.where( + (e) => !widget.blockedAssetIds.contains(e.key), + ); + return Map>.fromEntries(entries); + } + + @override + Widget build(BuildContext context) { + final hasBlocked = _hasBlockedAssetsInKeys(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const _TitleRow(), - const SizedBox(height: 16), - const _SecurityWarning(), - const SizedBox(height: 16), - const _CopyWarning(), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const _ShowingSwitcher(), - Flexible( - child: PrivateKeyActionsWidget(privateKeys: privateKeys), + const _ShowingSwitcher(), + if (hasBlocked) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: _IncludeBlockedToggle( + value: _includeBlockedAssets, + onChanged: (val) => + setState(() => _includeBlockedAssets = val), ), - ], + ), + Flexible( + child: PrivateKeyActionsWidget( + privateKeys: _filteredPrivateKeys(), + ), ), - const SizedBox(height: 16), - ExpandablePrivateKeyList(privateKeys: privateKeys), ], ), + const SizedBox(height: 16), + ExpandablePrivateKeyList(privateKeys: _filteredPrivateKeys()), ], - )); + ); + } +} + +class _IncludeBlockedToggle extends StatelessWidget { + const _IncludeBlockedToggle({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + UiSwitcher(value: value, onChanged: onChanged, width: 38, height: 21), + const SizedBox(width: 8), + Text( + LocaleKeys.includeBlockedAssets.tr(), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ); } } diff --git a/lib/views/settings/widgets/security_settings/security_settings_page.dart b/lib/views/settings/widgets/security_settings/security_settings_page.dart index 0924b13b7d..0cf6016229 100644 --- a/lib/views/settings/widgets/security_settings/security_settings_page.dart +++ b/lib/views/settings/widgets/security_settings/security_settings_page.dart @@ -24,6 +24,7 @@ import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/s import 'package:web_dex/views/settings/widgets/security_settings/private_key_settings/private_key_show.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/trading_status/trading_status_bloc.dart'; /// Security settings page that manages both seed phrase and private key backup flows. /// @@ -180,7 +181,15 @@ class _SecuritySettingsPageState extends State { return const SeedConfirmSuccess(); case SecuritySettingsStep.privateKeyShow: - return PrivateKeyShow(privateKeys: _sdkPrivateKeys ?? {}); + final tradingState = context.read().state; + final Set blockedAssets = switch (tradingState) { + TradingStatusLoadSuccess s => Set.of(s.disallowedAssets), + _ => const {}, + }; + return PrivateKeyShow( + privateKeys: _sdkPrivateKeys ?? >{}, + blockedAssetIds: blockedAssets, + ); case SecuritySettingsStep.passwordUpdate: _clearAllSensitiveData(); // Clear data when changing password @@ -243,6 +252,9 @@ class _SecuritySettingsPageState extends State { // Fetch private keys directly into local UI state // This keeps sensitive data in minimal scope final privateKeys = await context.sdk.security.getPrivateKeys(); + + // Filter out excluded assets (NFTs only) + // Geo-blocked assets are handled by the UI toggle final filteredPrivateKeyEntries = privateKeys.entries.where( (entry) => !excludedAssetList.contains(entry.key.id), ); diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart index afc00c0a3a..4b5f2d3954 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart @@ -150,6 +150,10 @@ class CoinDetailsCommonButtonsDesktopLayout extends StatelessWidget { @override Widget build(BuildContext context) { + final tradingState = context.watch().state; + final canTradeCoin = + !coin.walletOnly && tradingState.canTradeAssets([coin.id]); + return Row( children: [ ConstrainedBox( @@ -171,8 +175,7 @@ class CoinDetailsCommonButtonsDesktopLayout extends StatelessWidget { context: context, ), ), - if (!coin.walletOnly && - context.watch().state is TradingEnabled) + if (canTradeCoin) Container( margin: const EdgeInsets.only(left: 21), constraints: const BoxConstraints(maxWidth: 120), diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart index 58fd533149..ead0862bb8 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart @@ -236,6 +236,9 @@ class _DesktopCoinDetails extends StatelessWidget { @override Widget build(BuildContext context) { + final tradingState = context.watch().state; + final canTradeCoin = tradingState.canTradeAssets([coin.id]); + return Padding( padding: const EdgeInsets.only(right: 8.0), child: Column( @@ -263,8 +266,7 @@ class _DesktopCoinDetails extends StatelessWidget { child: CoinDetailsCommonButtons( isMobile: false, selectWidget: setPageType, - onClickSwapButton: - context.watch().state is TradingEnabled + onClickSwapButton: canTradeCoin ? () => _goToSwap(context, coin) : null, coin: coin, @@ -330,6 +332,9 @@ class _CoinDetailsInfoHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final tradingState = context.watch().state; + final canTradeCoin = tradingState.canTradeAssets([coin.id]); + return Container( padding: const EdgeInsets.fromLTRB(15, 18, 15, 16), decoration: BoxDecoration( @@ -352,8 +357,7 @@ class _CoinDetailsInfoHeader extends StatelessWidget { child: CoinDetailsCommonButtons( isMobile: true, selectWidget: setPageType, - onClickSwapButton: - context.watch().state is TradingEnabled + onClickSwapButton: canTradeCoin ? () => _goToSwap(context, coin) : null, coin: coin, diff --git a/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart b/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart index 1b06f631a5..b0ac070a09 100644 --- a/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart +++ b/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart @@ -284,6 +284,8 @@ class _AddressRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final tradingState = context.watch().state; + final canTradeCoin = tradingState.canTradeAssets([coin.id]); return ClipRRect( borderRadius: BorderRadius.circular(12), @@ -312,8 +314,7 @@ class _AddressRow extends StatelessWidget { visualDensity: VisualDensity.compact, ), ), - if (isSwapAddress && - context.watch().state is TradingEnabled) ...[ + if (isSwapAddress && canTradeCoin) ...[ const SizedBox(width: 8), // TODO: Refactor to use "DexPill" component from the SDK UI library (not yet created) Padding( diff --git a/lib/views/wallets_manager/widgets/wallet_deleting.dart b/lib/views/wallets_manager/widgets/wallet_deleting.dart index 4bb7ff15d2..bff0e672aa 100644 --- a/lib/views/wallets_manager/widgets/wallet_deleting.dart +++ b/lib/views/wallets_manager/widgets/wallet_deleting.dart @@ -12,7 +12,11 @@ import 'package:web_dex/shared/widgets/password_visibility_control.dart'; import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; class WalletDeleting extends StatefulWidget { - const WalletDeleting({super.key, required this.wallet, required this.close}); + const WalletDeleting({ + super.key, + required this.wallet, + required this.close, + }); final Wallet wallet; final VoidCallback close; @@ -43,19 +47,20 @@ class _WalletDeletingState extends State { padding: const EdgeInsets.only(top: 18.0), child: Text( LocaleKeys.deleteWalletTitle.tr(args: [widget.wallet.name]), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontSize: 16), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 16), ), ), Padding( padding: const EdgeInsets.only(top: 8.0), child: Text( LocaleKeys.deleteWalletInfo.tr(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w500), ), ), Padding( @@ -88,17 +93,20 @@ class _WalletDeletingState extends State { IconButton( alignment: Alignment.center, padding: const EdgeInsets.all(0), - icon: Icon(Icons.chevron_left, color: theme.custom.headerIconColor), + icon: Icon( + Icons.chevron_left, + color: theme.custom.headerIconColor, + ), splashRadius: 15, iconSize: 18, onPressed: widget.close, ), Text( LocaleKeys.back.tr(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 16, - ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.w600, fontSize: 16), ), ], ); @@ -127,7 +135,7 @@ class _WalletDeletingState extends State { height: 40, width: 150, ), - ), + ) ], ); } diff --git a/lib/views/wallets_manager/widgets/wallet_login.dart b/lib/views/wallets_manager/widgets/wallet_login.dart index c7338ae7ea..21fbc275b3 100644 --- a/lib/views/wallets_manager/widgets/wallet_login.dart +++ b/lib/views/wallets_manager/widgets/wallet_login.dart @@ -286,18 +286,14 @@ class _PasswordTextFieldState extends State { // Find common prefix int start = 0; - while (start < before.length && - start < after.length && - before[start] == after[start]) { + while (start < before.length && start < after.length && before[start] == after[start]) { start++; } // Find common suffix int endBefore = before.length - 1; int endAfter = after.length - 1; - while (endBefore >= start && - endAfter >= start && - before[endBefore] == after[endAfter]) { + while (endBefore >= start && endAfter >= start && before[endBefore] == after[endAfter]) { endBefore--; endAfter--; }