From 6ff31f39ee91d8988a24a28a98cb10c5ae06f423 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 24 Sep 2025 14:17:15 +0200 Subject: [PATCH 01/24] fix(screenshot-sensitivity): notify listeners in postFrameCallback fixes "setState() or markNeedsBuild() called when widget tree was locked. This ScreenshotSensitivity widget cannot be marked as needing to build because the framework is locked." and "DartError: setState() or markNeedsBuild() called during build. This ScreenshotSensitivity widget cannot be marked as needing to build because the framework is already in the process of building widgets. A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase." --- .../screenshot/screenshot_sensitivity.dart | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/lib/shared/screenshot/screenshot_sensitivity.dart b/lib/shared/screenshot/screenshot_sensitivity.dart index d4b7fb9f0f..a823bbb27e 100644 --- a/lib/shared/screenshot/screenshot_sensitivity.dart +++ b/lib/shared/screenshot/screenshot_sensitivity.dart @@ -9,32 +9,48 @@ class ScreenshotSensitivityController extends ChangeNotifier { void enter() { _depth += 1; - notifyListeners(); + _safeNotifyListeners(); } void exit() { if (_depth > 0) { _depth -= 1; - notifyListeners(); + _safeNotifyListeners(); } } + + /// Safely notify listeners, avoiding calls during widget tree locked phases + /// and calling build during a build or dismount. + void _safeNotifyListeners() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (hasListeners) { + notifyListeners(); + } + }); + } } /// Inherited notifier providing access to the ScreenshotSensitivityController. -class ScreenshotSensitivity extends InheritedNotifier { +class ScreenshotSensitivity + extends InheritedNotifier { const ScreenshotSensitivity({ super.key, required ScreenshotSensitivityController controller, - required Widget child, - }) : super(notifier: controller, child: child); + required super.child, + }) : super(notifier: controller); static ScreenshotSensitivityController? maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType()?.notifier; + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier; } static ScreenshotSensitivityController of(BuildContext context) { final controller = maybeOf(context); - assert(controller != null, 'ScreenshotSensitivity not found in widget tree'); + assert( + controller != null, + 'ScreenshotSensitivity not found in widget tree', + ); return controller!; } } @@ -51,21 +67,31 @@ class ScreenshotSensitive extends StatefulWidget { class _ScreenshotSensitiveState extends State { ScreenshotSensitivityController? _controller; + bool _hasCalledEnter = false; @override void didChangeDependencies() { super.didChangeDependencies(); final controller = ScreenshotSensitivity.maybeOf(context); if (!identical(controller, _controller)) { - _controller?.exit(); + // Exit the old controller if we were using it + if (_hasCalledEnter) { + _controller?.exit(); + } _controller = controller; + _hasCalledEnter = false; + // Enter the new controller - this is safe now due to deferred notification _controller?.enter(); + _hasCalledEnter = true; } } @override void dispose() { - _controller?.exit(); + // Exit the controller - this is safe now due to deferred notification + if (_hasCalledEnter) { + _controller?.exit(); + } super.dispose(); } @@ -77,4 +103,3 @@ extension ScreenshotSensitivityContextExt on BuildContext { bool get isScreenshotSensitive => ScreenshotSensitivity.maybeOf(this)?.isSensitive ?? false; } - From 212ad6d1d24b0c02790e38cae14646b28c56b747 Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 25 Sep 2025 17:52:30 +0200 Subject: [PATCH 02/24] chore(sdk): switch to zhtlc branch --- .gitmodules | 2 +- lib/app_config/app_config.dart | 7 ------- sdk | 2 +- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.gitmodules b/.gitmodules index ad07a1ea79..cb4eb4a2a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "sdk"] path = sdk url = https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - branch = dev + branch = bugfix/zhltc-activation-fixes update = checkout fetchRecurseSubmodules = on-demand ignore = dirty diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart index 551b99947e..8378cbfba7 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -107,15 +107,8 @@ const Set excludedAssetList = { 'FENIX', 'AWR', 'BOT', - // Pirate activation params are not yet implemented, so we need to - // exclude it from the list of coins for now. - 'ARRR', - 'ZOMBIE', 'SMTF-v2', 'SFUSD', - 'VOTE2023', - 'RICK', - 'MORTY', // NFT v2 coins: https://github.com/KomodoPlatform/coins/pull/1061 will be // used in the background, so users do not need to see them. diff --git a/sdk b/sdk index 1cd61c95d3..0d6b12ca53 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 1cd61c95d3c9189ec44e06fea1e9c559b6110269 +Subproject commit 0d6b12ca533014fb658a07a7f8dbe7db07d6d733 From 23ded71e0cdb9b3847973161bf909a02fca60f74 Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 26 Sep 2025 14:24:17 +0200 Subject: [PATCH 03/24] feat(zhtlc): add configuration and param download dialogs --- assets/translations/en.json | 6 +- lib/bloc/app_bloc_root.dart | 1 + lib/bloc/coins_bloc/coins_repo.dart | 135 ++++++- lib/generated/codegen_loader.g.dart | 2 + lib/main.dart | 3 +- .../arrr_activation_service.dart | 309 ++++++++++++++ lib/services/arrr_activation/arrr_config.dart | 191 +++++++++ .../initializer/app_bootstrapper.dart | 3 + lib/views/bridge/bridge_page.dart | 36 +- lib/views/dex/dex_page.dart | 12 +- .../coins_manager/coins_manager_page.dart | 52 +-- .../zhtlc/zhtlc_activation_status_bar.dart | 125 ++++++ .../zhtlc/zhtlc_configuration_dialog.dart | 377 ++++++++++++++++++ .../zhtlc/zhtlc_configuration_handler.dart | 136 +++++++ .../wallet_main/active_coins_list.dart | 63 ++- .../wallet_page/wallet_main/wallet_main.dart | 173 ++++---- 16 files changed, 1470 insertions(+), 154 deletions(-) create mode 100644 lib/services/arrr_activation/arrr_activation_service.dart create mode 100644 lib/services/arrr_activation/arrr_config.dart create mode 100644 lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart create mode 100644 lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart create mode 100644 lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 02f1986560..10889f0bf6 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -761,5 +761,9 @@ "fetchingPrivateKeysTitle": "Fetching Private Keys...", "fetchingPrivateKeysMessage": "Please wait while we securely fetch your private keys...", "pubkeyType": "Type", - "securitySettings": "Security Settings" + "securitySettings": "Security Settings", + "activatingZhtlcCoins": { + "one": "Activating ZHTLC coin: {}", + "other": "Activating ZHTLC coins: {}" + } } \ No newline at end of file diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 4cee603b84..c585e2e7e4 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -23,6 +23,7 @@ import 'package:web_dex/bloc/bridge_form/bridge_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/price_chart/price_chart_bloc.dart'; diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 32e142500b..272a3edc05 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -4,6 +4,7 @@ import 'dart:math' show min; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart' show NetworkImage; +import 'package:get_it/get_it.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -27,6 +28,7 @@ import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; class CoinsRepo { CoinsRepo({required KomodoDefiSdk kdfSdk, required MM2 mm2}) @@ -82,7 +84,7 @@ class CoinsRepo { BalanceInfo? lastKnownBalance(AssetId id) => _kdfSdk.balances.lastKnown(id); /// Subscribe to balance updates for an asset using the SDK's balance manager - void _subscribeToBalanceUpdates(Asset asset, Coin coin) { + void _subscribeToBalanceUpdates(Asset asset) { // Cancel any existing subscription for this asset _balanceWatchers[asset.id]?.cancel(); @@ -280,6 +282,15 @@ class CoinsRepo { return; } + if (assets.any((asset) => asset.id.subClass == CoinSubClass.zhtlc)) { + return _activateZhtlcAssets( + assets, + assets.map((asset) => _assetToCoinWithoutAddress(asset)).toList(), + notifyListeners: notifyListeners, + addToWalletMetadata: addToWalletMetadata, + ); + } + if (addToWalletMetadata) { // Ensure the wallet metadata is updated with the assets before activation // This is to ensure that the wallet metadata is always in sync with the assets @@ -333,13 +344,13 @@ class CoinsRepo { _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); } } - _subscribeToBalanceUpdates(asset, coin); + _subscribeToBalanceUpdates(asset); if (coin.id.parentId != null) { final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; if (parentAsset == null) { _log.warning('Parent asset not found: ${coin.id.parentId}'); } else { - _subscribeToBalanceUpdates(parentAsset, coin); + _subscribeToBalanceUpdates(parentAsset); } } } catch (e, s) { @@ -702,4 +713,122 @@ class CoinsRepo { return BlocResponse(result: withdrawDetails); } + + Future _activateZhtlcAssets( + List assets, + List coins, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + }) async { + for (final asset in assets) { + final coin = coins.firstWhere((coin) => coin.id == asset.id); + await _activateZhtlcAsset( + asset, + coin, + notifyListeners: notifyListeners, + addToWalletMetadata: addToWalletMetadata, + ); + } + } + + /// Activates a ZHTLC asset using ArrrActivationService + /// This will wait for user configuration if needed before proceeding with activation + /// Mirrors the notify and addToWalletMetadata functionality of activateAssetsSync + Future _activateZhtlcAsset( + Asset asset, + Coin coin, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + }) async { + try { + final arrrActivationService = GetIt.I(); + + _log.info('Starting ZHTLC activation for ${asset.id.id}'); + + // Use the service's future-based activation which will handle configuration + // The service will emit to its stream for UI to handle, and this future will + // complete only after configuration is provided and activation succeeds. + // This ensures CoinsRepo waits for user inputs for config params from the dialog + // before proceeding with activation, and doesn't broadcast activation status + // until config parameters are received and (desktop) params files downloaded. + final result = await arrrActivationService.activateArrr(asset); + + result.when( + success: (progress) async { + _log.info('ZHTLC asset activated successfully: ${asset.id.id}'); + + if (addToWalletMetadata) { + await _addAssetsToWalletMetdata([asset.id]); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.active)); + if (coin.id.parentId != null) { + final parentCoin = _assetToCoinWithoutAddress( + _kdfSdk.assets.available[coin.id.parentId]!, + ); + _broadcastAsset(parentCoin.copyWith(state: CoinState.active)); + } + } + + _subscribeToBalanceUpdates(asset); + if (coin.id.parentId != null) { + final parentAsset = _kdfSdk.assets.available[coin.id.parentId]; + if (parentAsset == null) { + _log.warning('Parent asset not found: ${coin.id.parentId}'); + } else { + _subscribeToBalanceUpdates(parentAsset); + } + } + + if (coin.logoImageUrl?.isNotEmpty ?? false) { + AssetIcon.registerCustomIcon( + coin.id, + NetworkImage(coin.logoImageUrl!), + ); + } + }, + error: (message) { + _log.severe( + 'ZHTLC asset activation failed: ${asset.id.id} - $message', + ); + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + throw Exception('ZHTLC activation failed: $message'); + }, + needsConfiguration: (coinId, requiredSettings) { + _log.severe( + 'ZHTLC activation should not return needsConfiguration in future-based call', + ); + _log.severe( + 'Unexpected needsConfiguration result for ${asset.id.id}', + ); + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + throw Exception( + 'ZHTLC activation configuration not handled properly', + ); + }, + ); + } catch (e, s) { + _log.severe('Error activating ZHTLC asset ${asset.id.id}', e, s); + + // Broadcast suspended state if requested + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } + + rethrow; + } + } } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 5dc16c40b1..60cb50aad5 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -799,4 +799,6 @@ abstract class LocaleKeys { static const fetchingPrivateKeysMessage = 'fetchingPrivateKeysMessage'; static const pubkeyType = 'pubkeyType'; static const securitySettings = 'securitySettings'; + static const activatingZhtlcCoins = 'activatingZhtlcCoins'; + } diff --git a/lib/main.dart b/lib/main.dart index 4f7902a1f9..23a1127413 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,11 +28,12 @@ import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/model/stored_settings.dart'; import 'package:web_dex/performance_analytics/performance_analytics.dart'; import 'package:web_dex/sdk/widgets/window_close_handler.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/services/feedback/app_feedback_wrapper.dart'; import 'package:web_dex/services/logger/get_logger.dart'; -import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/services/storage/get_storage.dart'; import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/screenshot/screenshot_sensitivity.dart'; import 'package:web_dex/shared/utils/platform_tuner.dart'; import 'package:web_dex/shared/utils/utils.dart'; diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart new file mode 100644 index 0000000000..82af0e8d5b --- /dev/null +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -0,0 +1,309 @@ +import 'dart:async'; + +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +import 'arrr_config.dart'; + +/// Service layer - business logic coordination for ARRR activation +class ArrrActivationService { + ArrrActivationService(this._sdk) + : _configService = _sdk.activationConfigService; + + final ActivationConfigService _configService; + final KomodoDefiSdk _sdk; + final Logger _log = Logger('ArrrActivationService'); + + /// Stream controller for configuration requests + final StreamController _configRequestController = + StreamController.broadcast(); + + /// Completer to wait for configuration when needed + final Map> _configCompleters = {}; + + /// Stream of configuration requests that UI can listen to + Stream get configurationRequests => + _configRequestController.stream; + + /// Future-based activation (for CoinsRepo consumers) + /// This method will wait for user configuration if needed + Future activateArrr( + Asset asset, { + ZhtlcUserConfig? initialConfig, + }) async { + var config = initialConfig ?? await _getOrRequestConfiguration(asset.id); + + if (config == null) { + final requiredSettings = await _getRequiredSettings(asset.id); + + final configRequest = ZhtlcConfigurationRequest( + asset: asset, + requiredSettings: requiredSettings, + ); + + final completer = Completer(); + _configCompleters[asset.id] = completer; + + _log.info('Requesting configuration for ${asset.id.id}'); + + // Check if stream controller is closed + if (_configRequestController.isClosed) { + _log.severe( + 'Configuration request controller is closed for ${asset.id.id}', + ); + return ArrrActivationResultError( + 'Configuration system is not available', + ); + } + + // Wait for UI listeners to be ready before emitting request + await _waitForUIListeners(asset.id); + + try { + _configRequestController.add(configRequest); + _log.info('Configuration request emitted for ${asset.id.id}'); + } catch (e, stackTrace) { + _log.severe( + 'Failed to emit configuration request for ${asset.id.id}', + e, + stackTrace, + ); + return ArrrActivationResultError('Failed to request configuration: $e'); + } + + try { + config = await completer.future.timeout( + const Duration(minutes: 15), + onTimeout: () { + _log.warning('Configuration request timed out for ${asset.id.id}'); + return null; + }, + ); + } finally { + _configCompleters.remove(asset.id); + } + + if (config == null) { + _log.info('Configuration cancelled/timed out for ${asset.id.id}'); + return ArrrActivationResultError( + 'Configuration cancelled by user or timed out', + ); + } + + _log.info('Configuration received for ${asset.id.id}'); + } + + _log.info('Starting activation with configuration for ${asset.id.id}'); + return _performActivation(asset, config); + } + + /// Perform the actual activation with configuration + Future _performActivation( + Asset asset, + ZhtlcUserConfig config, + ) async { + try { + _cacheActivationStart(asset.id); + + await for (final progress in _sdk.assets.activateAsset(asset)) { + _cacheActivationProgress(asset.id, progress); + } + + _cacheActivationComplete(asset.id); + return ArrrActivationResultSuccess( + Stream.value( + ActivationProgress( + status: 'Activation completed successfully', + progressDetails: ActivationProgressDetails( + currentStep: ActivationStep.complete, + stepCount: 1, + ), + ), + ), + ); + } catch (e) { + _cacheActivationError(asset.id, e.toString()); + return ArrrActivationResultError(e.toString()); + } + } + + Future _getOrRequestConfiguration(AssetId assetId) async { + final existing = await _configService.getSavedZhtlc(assetId); + if (existing != null) return existing; + + return null; + } + + Future> _getRequiredSettings( + AssetId assetId, + ) async { + return assetId.activationSettings(); + } + + /// Activation status caching for UI display + final Map _activationCache = {}; + + void _cacheActivationStart(AssetId assetId) { + _activationCache[assetId] = ArrrActivationStatusInProgress( + assetId: assetId, + startTime: DateTime.now(), + ); + } + + void _cacheActivationProgress(AssetId assetId, ActivationProgress progress) { + final current = _activationCache[assetId]; + if (current is ArrrActivationStatusInProgress) { + _activationCache[assetId] = (current).copyWith( + progressPercentage: progress.progressPercentage?.toInt(), + currentStep: progress.progressDetails?.currentStep, + statusMessage: progress.status, + ); + } + } + + void _cacheActivationComplete(AssetId assetId) { + _activationCache[assetId] = ArrrActivationStatusCompleted( + assetId: assetId, + completionTime: DateTime.now(), + ); + } + + void _cacheActivationError(AssetId assetId, String errorMessage) { + _activationCache[assetId] = ArrrActivationStatusError( + assetId: assetId, + errorMessage: errorMessage, + errorTime: DateTime.now(), + ); + } + + // Public method for UI to check activation status + ArrrActivationStatus? getActivationStatus(AssetId assetId) { + return _activationCache[assetId]; + } + + // Public method for UI to get all cached activation statuses + Map get activationStatuses => + _activationCache.unmodifiable(); + + // Clear cached status when no longer needed + void clearActivationStatus(AssetId assetId) { + _activationCache.remove(assetId); + } + + /// Submit configuration for a pending request + /// Called by UI when user provides configuration + Future submitConfiguration( + AssetId assetId, + ZhtlcUserConfig config, + ) async { + _log.info('Submitting configuration for ${assetId.id}'); + + // Save configuration to SDK + final completer = _configCompleters[assetId]; + try { + await _configService.saveZhtlcConfig(assetId, config); + _log.info('Configuration saved to SDK for ${assetId.id}'); + } catch (e) { + _log.severe('Failed to save configuration to SDK for ${assetId.id}: $e'); + completer?.completeError('Failed to save configuration: $e'); + } + + if (completer != null && !completer.isCompleted) { + completer.complete(config); + } else { + _log.warning('No pending completer found for ${assetId.id}'); + } + } + + /// Cancel configuration for a pending request + /// Called by UI when user cancels configuration + void cancelConfiguration(AssetId assetId) { + _log.info('Cancelling configuration for ${assetId.id}'); + final completer = _configCompleters[assetId]; + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } else { + _log.warning('No pending completer found for ${assetId.id}'); + } + } + + /// Get diagnostic information about the configuration request system + Map getConfigurationSystemDiagnostics() { + return { + 'hasListeners': _configRequestController.hasListener, + 'isClosed': _configRequestController.isClosed, + 'pendingCompleters': _configCompleters.keys.map((id) => id.id).toList(), + 'handledConfigurations': _configCompleters.length, + }; + } + + /// Test method to verify configuration request system is working + /// This will log diagnostic information + void diagnoseConfigurationSystem() { + final diagnostics = getConfigurationSystemDiagnostics(); + _log.info('Configuration system diagnostics: $diagnostics'); + + if (!_configRequestController.hasListener) { + _log.warning( + 'No listeners detected for configuration requests. ' + 'Make sure ZhtlcConfigurationHandler is in the widget tree.', + ); + } + + if (_configRequestController.isClosed) { + _log.severe('Configuration request controller is closed!'); + } + } + + /// Wait for UI listeners to be ready before emitting configuration requests + /// This ensures the ZhtlcConfigurationHandler is properly initialized + Future _waitForUIListeners(AssetId assetId) async { + const maxWaitTime = Duration(seconds: 10); + const checkInterval = Duration(milliseconds: 100); + final stopwatch = Stopwatch()..start(); + + while (!_configRequestController.hasListener && + stopwatch.elapsed < maxWaitTime) { + _log.info('Waiting for UI listeners to be ready for ${assetId.id}...'); + await Future.delayed(checkInterval); + } + + if (!_configRequestController.hasListener) { + _log.warning( + 'No UI listeners detected after ${maxWaitTime.inSeconds} seconds for ${assetId.id}. ' + 'Make sure ZhtlcConfigurationHandler is in the widget tree.', + ); + } else { + _log.info( + 'UI listeners ready for ${assetId.id} after ${stopwatch.elapsed.inMilliseconds}ms', + ); + } + + stopwatch.stop(); + } + + /// Dispose resources + void dispose() { + // Complete any pending configuration requests + for (final completer in _configCompleters.values) { + if (!completer.isCompleted) { + completer.complete(null); + } + } + _configCompleters.clear(); + _configRequestController.close(); + } +} + +/// Configuration request model for UI handling +class ZhtlcConfigurationRequest { + const ZhtlcConfigurationRequest({ + required this.asset, + required this.requiredSettings, + }); + + final Asset asset; + final List requiredSettings; +} diff --git a/lib/services/arrr_activation/arrr_config.dart b/lib/services/arrr_activation/arrr_config.dart new file mode 100644 index 0000000000..2c70f9dd0b --- /dev/null +++ b/lib/services/arrr_activation/arrr_config.dart @@ -0,0 +1,191 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// ARRR activation result for Future-based API +abstract class ArrrActivationResult { + const ArrrActivationResult(); + + T when({ + required T Function(Stream progress) success, + required T Function( + String coinId, + List requiredSettings, + ) + needsConfiguration, + required T Function(String message) error, + }) { + if (this is ArrrActivationResultSuccess) { + final self = this as ArrrActivationResultSuccess; + return success(self.progress); + } else if (this is ArrrActivationResultNeedsConfig) { + final self = this as ArrrActivationResultNeedsConfig; + return needsConfiguration(self.coinId, self.requiredSettings); + } else if (this is ArrrActivationResultError) { + final self = this as ArrrActivationResultError; + return error(self.message); + } + throw StateError('Unknown ArrrActivationResult type: $runtimeType'); + } +} + +class ArrrActivationResultSuccess extends ArrrActivationResult { + const ArrrActivationResultSuccess(this.progress); + + final Stream progress; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultSuccess && other.progress == progress; + } + + @override + int get hashCode => progress.hashCode; +} + +class ArrrActivationResultNeedsConfig extends ArrrActivationResult { + const ArrrActivationResultNeedsConfig({ + required this.coinId, + required this.requiredSettings, + }); + + final String coinId; + final List requiredSettings; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultNeedsConfig && + other.coinId == coinId && + other.requiredSettings == requiredSettings; + } + + @override + int get hashCode => Object.hash(coinId, requiredSettings); +} + +class ArrrActivationResultError extends ArrrActivationResult { + const ArrrActivationResultError(this.message); + + final String message; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ArrrActivationResultError && other.message == message; + } + + @override + int get hashCode => message.hashCode; +} + +/// ARRR activation status for UI caching +abstract class ArrrActivationStatus extends Equatable { + const ArrrActivationStatus(); + + T when({ + required T Function( + AssetId assetId, + DateTime startTime, + int? progressPercentage, + ActivationStep? currentStep, + String? statusMessage, + ) + inProgress, + required T Function(AssetId assetId, DateTime completionTime) completed, + required T Function( + AssetId assetId, + String errorMessage, + DateTime errorTime, + ) + error, + }) { + if (this is ArrrActivationStatusInProgress) { + final self = this as ArrrActivationStatusInProgress; + return inProgress( + self.assetId, + self.startTime, + self.progressPercentage, + self.currentStep, + self.statusMessage, + ); + } else if (this is ArrrActivationStatusCompleted) { + final self = this as ArrrActivationStatusCompleted; + return completed(self.assetId, self.completionTime); + } else if (this is ArrrActivationStatusError) { + final self = this as ArrrActivationStatusError; + return error(self.assetId, self.errorMessage, self.errorTime); + } + throw StateError('Unknown ArrrActivationStatus type: $runtimeType'); + } +} + +class ArrrActivationStatusInProgress extends ArrrActivationStatus { + const ArrrActivationStatusInProgress({ + required this.assetId, + required this.startTime, + this.progressPercentage, + this.currentStep, + this.statusMessage, + }); + + final AssetId assetId; + final DateTime startTime; + final int? progressPercentage; + final ActivationStep? currentStep; + final String? statusMessage; + + ArrrActivationStatusInProgress copyWith({ + AssetId? assetId, + DateTime? startTime, + int? progressPercentage, + ActivationStep? currentStep, + String? statusMessage, + }) { + return ArrrActivationStatusInProgress( + assetId: assetId ?? this.assetId, + startTime: startTime ?? this.startTime, + progressPercentage: progressPercentage ?? this.progressPercentage, + currentStep: currentStep ?? this.currentStep, + statusMessage: statusMessage ?? this.statusMessage, + ); + } + + @override + List get props => [ + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ]; +} + +class ArrrActivationStatusCompleted extends ArrrActivationStatus { + const ArrrActivationStatusCompleted({ + required this.assetId, + required this.completionTime, + }); + + final AssetId assetId; + final DateTime completionTime; + + @override + List get props => [assetId, completionTime]; +} + +class ArrrActivationStatusError extends ArrrActivationStatus { + const ArrrActivationStatusError({ + required this.assetId, + required this.errorMessage, + required this.errorTime, + }); + + final AssetId assetId; + final String errorMessage; + final DateTime errorTime; + + @override + List get props => [assetId, errorMessage, errorTime]; +} diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart index b1a5e13453..4b6b38605b 100644 --- a/lib/services/initializer/app_bootstrapper.dart +++ b/lib/services/initializer/app_bootstrapper.dart @@ -40,6 +40,9 @@ final class AppBootstrapper { // Register core services GetIt.I.registerSingleton(kdfSdk); GetIt.I.registerSingleton(mm2Api); + + final arrrActivationService = ArrrActivationService( kdfSdk); + GetIt.I.registerSingleton(arrrActivationService); } /// A list of futures that should be completed before the app starts diff --git a/lib/views/bridge/bridge_page.dart b/lib/views/bridge/bridge_page.dart index 6f8b54fe8c..32456a02d8 100644 --- a/lib/views/bridge/bridge_page.dart +++ b/lib/views/bridge/bridge_page.dart @@ -16,6 +16,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/entities_list/history/history_list.dart'; import 'package:web_dex/views/dex/entities_list/in_progress/in_progress_list.dart'; import 'package:web_dex/views/dex/entity_details/trading_details.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart'; class BridgePage extends StatefulWidget { const BridgePage() : super(key: const Key('bridge-page')); @@ -50,17 +51,21 @@ class _BridgePageState extends State with TickerProviderStateMixin { }); } }, - child: Builder(builder: (context) { - final page = _showSwap ? _buildTradingDetails() : _buildBridgePage(); - return page; - }), + child: ZhtlcConfigurationHandler( + child: Builder( + builder: (context) { + final page = _showSwap + ? _buildTradingDetails() + : _buildBridgePage(); + return page; + }, + ), + ), ); } Widget _buildTradingDetails() { - return TradingDetails( - uuid: routingState.bridgeState.uuid, - ); + return TradingDetails(uuid: routingState.bridgeState.uuid); } Widget _buildBridgePage() { @@ -78,8 +83,9 @@ class _BridgePageState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.center, children: [ ConstrainedBox( - constraints: - BoxConstraints(maxWidth: theme.custom.dexFormWidth), + constraints: BoxConstraints( + maxWidth: theme.custom.dexFormWidth, + ), child: HiddenWithoutWallet( child: BridgeTabBar( currentTabIndex: _activeTabIndex, @@ -91,11 +97,7 @@ class _BridgePageState extends State with TickerProviderStateMixin { padding: EdgeInsets.only(top: 12.0), child: ClockWarningBanner(), ), - Flexible( - child: _TabContent( - activeTabIndex: _activeTabIndex, - ), - ), + Flexible(child: _TabContent(activeTabIndex: _activeTabIndex)), ], ), ), @@ -128,7 +130,7 @@ class _BridgePageState extends State with TickerProviderStateMixin { class _TabContent extends StatelessWidget { final int _activeTabIndex; const _TabContent({required int activeTabIndex}) - : _activeTabIndex = activeTabIndex; + : _activeTabIndex = activeTabIndex; @override Widget build(BuildContext context) { @@ -137,7 +139,9 @@ class _TabContent extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 20), child: InProgressList( - filter: _bridgeSwapsFilter, onItemClick: _onSwapItemClick), + filter: _bridgeSwapsFilter, + onItemClick: _onSwapItemClick, + ), ), Padding( padding: const EdgeInsets.only(top: 20), diff --git a/lib/views/dex/dex_page.dart b/lib/views/dex/dex_page.dart index 0224ea28cb..5acf92bf94 100644 --- a/lib/views/dex/dex_page.dart +++ b/lib/views/dex/dex_page.dart @@ -17,6 +17,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/dex_tab_bar.dart'; import 'package:web_dex/views/dex/entities_list/dex_list_wrapper.dart'; import 'package:web_dex/views/dex/entity_details/trading_details.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart'; class DexPage extends StatefulWidget { const DexPage({super.key}); @@ -42,8 +43,9 @@ class _DexPageState extends State { @override Widget build(BuildContext context) { - final tradingEntitiesBloc = - RepositoryProvider.of(context); + final tradingEntitiesBloc = RepositoryProvider.of( + context, + ); final coinsRepository = RepositoryProvider.of(context); final myOrdersService = RepositoryProvider.of(context); @@ -66,13 +68,11 @@ class _DexPageState extends State { ? TradingDetails(uuid: routingState.dexState.uuid) : _DexContent(), ); - return pageContent; + return ZhtlcConfigurationHandler(child: pageContent); } void _onRouteChange() { - setState( - () => isTradingDetails = routingState.dexState.isTradingDetails, - ); + setState(() => isTradingDetails = routingState.dexState.isTradingDetails); } } diff --git a/lib/views/wallet/coins_manager/coins_manager_page.dart b/lib/views/wallet/coins_manager/coins_manager_page.dart index ad26acfbd7..00810f0475 100644 --- a/lib/views/wallet/coins_manager/coins_manager_page.dart +++ b/lib/views/wallet/coins_manager/coins_manager_page.dart @@ -3,20 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; -import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/router/state/wallet_state.dart'; import 'package:web_dex/views/common/page_header/page_header.dart'; import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_list_wrapper.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart' + show ZhtlcConfigurationHandler; class CoinsManagerPage extends StatelessWidget { const CoinsManagerPage({ - Key? key, + super.key, required this.action, required this.closePage, - }) : super(key: key); + }); final CoinsManagerAction action; final void Function() closePage; @@ -31,27 +31,29 @@ class CoinsManagerPage extends StatelessWidget { ? LocaleKeys.addAssets.tr() : LocaleKeys.removeAssets.tr(); - return PageLayout( - header: PageHeader( - title: title, - backText: LocaleKeys.backToWallet.tr(), - onBackButtonPressed: closePage, - ), - content: Flexible( - child: Padding( - padding: const EdgeInsets.only(top: 20.0), - child: BlocBuilder( - builder: (context, state) { - if (!state.isSignedIn) { - return const Center( - child: Padding( - padding: EdgeInsets.fromLTRB(0, 100, 0, 100), - child: UiSpinner(), - ), - ); - } - return const CoinsManagerListWrapper(); - }, + return ZhtlcConfigurationHandler( + child: PageLayout( + header: PageHeader( + title: title, + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: closePage, + ), + content: Flexible( + child: Padding( + padding: const EdgeInsets.only(top: 20.0), + child: BlocBuilder( + builder: (context, state) { + if (!state.isSignedIn) { + return const Center( + child: Padding( + padding: EdgeInsets.fromLTRB(0, 100, 0, 100), + child: UiSpinner(), + ), + ); + } + return const CoinsManagerListWrapper(); + }, + ), ), ), ), diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart new file mode 100644 index 0000000000..614f17a71e --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; +import 'package:web_dex/generated/codegen_loader.g.dart' show LocaleKeys; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; +import 'package:web_dex/services/arrr_activation/arrr_config.dart'; + +/// Status bar widget to display ZHTLC activation progress for multiple coins +class ZhtlcActivationStatusBar extends StatefulWidget { + const ZhtlcActivationStatusBar({super.key, required this.activationService}); + + final ArrrActivationService activationService; + + @override + State createState() => + _ZhtlcActivationStatusBarState(); +} + +class _ZhtlcActivationStatusBarState extends State { + Timer? _refreshTimer; + Map _cachedStatuses = {}; + + @override + void initState() { + super.initState(); + _startPeriodicRefresh(); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + + void _startPeriodicRefresh() { + _refreshTimer = Timer.periodic(const Duration(seconds: 1), (_) { + _refreshStatuses(); + }); + } + + void _refreshStatuses() { + final newStatuses = widget.activationService.activationStatuses; + + if (mounted) { + setState(() { + _cachedStatuses = newStatuses; + }); + } + } + + @override + Widget build(BuildContext context) { + // Filter out completed or error statuses older than 5 seconds + final activeStatuses = _cachedStatuses.entries.where((entry) { + final status = entry.value; + return status.when( + inProgress: + ( + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ) => true, + completed: (coinId, completionTime) => + DateTime.now().difference(completionTime).inSeconds < 5, + error: (coinId, errorMessage, errorTime) => + DateTime.now().difference(errorTime).inSeconds < 5, + ); + }).toList(); + + if (activeStatuses.isEmpty) { + return const SizedBox.shrink(); + } + + final coinNames = activeStatuses.map((entry) => entry.key.id).join(', '); + final coinCount = activeStatuses.length; + return Container( + margin: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + LocaleKeys.activatingZhtlcCoins.plural( + coinCount, + args: [coinNames], + ), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart new file mode 100644 index 0000000000..8fdd6a9f94 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show ZhtlcSyncParams; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' + show + ZhtlcUserConfig, + ZcashParamsDownloader, + ZcashParamsDownloaderFactory, + DownloadProgress, + DownloadResultSuccess; +import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Shows ZHTLC configuration dialog similar to handleZhtlcConfigDialog from SDK example +/// This is bad practice (UI logic in utils), but necessary for now because of +/// auto-coin activations from multiple sources in BLoCs. +Future confirmZhtlcConfiguration( + BuildContext context, { + required Asset asset, +}) async { + String? prefilledZcashPath; + + // On desktop platforms, try to download Zcash parameters first + if (ZcashParamsDownloaderFactory.requiresDownload) { + ZcashParamsDownloader? downloader; + try { + downloader = ZcashParamsDownloaderFactory.create(); + + // Check if parameters are already available + final areAvailable = await downloader.areParamsAvailable(); + if (!areAvailable) { + // Show download progress dialog + final downloadResult = await _showZcashDownloadDialog( + context, + downloader, + ); + + if (downloadResult == false) { + // User cancelled the download + return null; + } + } + + prefilledZcashPath = await downloader.getParamsPath(); + } catch (e) { + // Error creating downloader or getting params path + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error setting up Zcash parameters: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + // Always dispose the downloader to release resources + downloader?.dispose(); + } + } + + // On web, use './zcash-params' as default, otherwise use prefilledZcashPath + final defaultZcashPath = kIsWeb ? './zcash-params' : prefilledZcashPath; + final zcashPathController = TextEditingController(text: defaultZcashPath); + final blocksPerIterController = TextEditingController(text: '1000'); + final intervalMsController = TextEditingController(text: '0'); + + var syncType = 'date'; // earliest | height | date + final syncValueController = TextEditingController(); + DateTime? selectedDateTime; + + String formatDate(DateTime dateTime) { + return dateTime.toIso8601String().split('T')[0]; + } + + Future selectDate(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: selectedDateTime ?? DateTime.now(), + firstDate: DateTime(2018), // first arrr block in 2018 + lastDate: DateTime.now(), + builder: (context, child) { + return child ?? const SizedBox(); + }, + ); + + if (picked != null) { + selectedDateTime = DateTime(picked.year, picked.month, picked.day); + syncValueController.text = formatDate(selectedDateTime!); + } + } + + // Initialize with default date (2 days ago) + selectedDateTime = DateTime.now().subtract(const Duration(days: 2)); + syncValueController.text = formatDate(selectedDateTime!); + + ZhtlcUserConfig? result; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return StatefulBuilder( + builder: (context, setInnerState) { + return AlertDialog( + title: Text('Configure ${asset.id.id}'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!kIsWeb) ...[ + TextField( + controller: zcashPathController, + readOnly: prefilledZcashPath != null, + decoration: InputDecoration( + labelText: 'Zcash parameters path', + helperText: prefilledZcashPath != null + ? 'Path automatically detected' + : 'Folder containing sapling params', + ), + ), + const SizedBox(height: 12), + ], + TextField( + controller: blocksPerIterController, + decoration: const InputDecoration( + labelText: 'Blocks per iteration', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextField( + controller: intervalMsController, + decoration: const InputDecoration( + labelText: 'Scan interval (ms)', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Start sync from:'), + const SizedBox(width: 12), + DropdownButton( + value: syncType, + items: const [ + DropdownMenuItem( + value: 'earliest', + child: Text('Earliest (sapling)'), + ), + DropdownMenuItem( + value: 'height', + child: Text('Block height'), + ), + DropdownMenuItem( + value: 'date', + child: Text('Date & Time'), + ), + ], + onChanged: (v) { + if (v == null) return; + setInnerState(() => syncType = v); + }, + ), + const SizedBox(width: 8), + if (syncType != 'earliest') + Expanded( + child: TextField( + controller: syncValueController, + decoration: InputDecoration( + labelText: syncType == 'height' + ? 'Block height' + : 'Select date & time', + suffixIcon: syncType == 'date' + ? IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: () => selectDate(context), + ) + : null, + ), + keyboardType: syncType == 'height' + ? TextInputType.number + : TextInputType.none, + readOnly: syncType == 'date', + onTap: syncType == 'date' + ? () => selectDate(context) + : null, + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final path = zcashPathController.text.trim(); + // On web, allow empty path, otherwise require it + if (!kIsWeb && path.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Zcash params path is required'), + ), + ); + return; + } + + // Create sync params based on type + ZhtlcSyncParams? syncParams; + if (syncType == 'earliest') { + syncParams = ZhtlcSyncParams.earliest(); + } else if (syncType == 'height') { + final v = int.tryParse(syncValueController.text.trim()); + if (v == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Enter a valid block height'), + ), + ); + return; + } + syncParams = ZhtlcSyncParams.height(v); + } else if (syncType == 'date') { + if (selectedDateTime == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a date and time'), + ), + ); + return; + } + // Convert to Unix timestamp (seconds since epoch) + final unixTimestamp = + selectedDateTime!.millisecondsSinceEpoch ~/ 1000; + syncParams = ZhtlcSyncParams.date(unixTimestamp); + } + + result = ZhtlcUserConfig( + zcashParamsPath: path, + scanBlocksPerIteration: + int.tryParse(blocksPerIterController.text) ?? 1000, + scanIntervalMs: + int.tryParse(intervalMsController.text) ?? 0, + syncParams: syncParams, + ); + Navigator.of(context).pop(); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + return result; +} + +/// Shows a download progress dialog for Zcash parameters +Future _showZcashDownloadDialog( + BuildContext context, + ZcashParamsDownloader downloader, +) async { + const downloadTimeout = Duration(minutes: 10); + + // Start the download + final downloadFuture = downloader.downloadParams().timeout( + downloadTimeout, + onTimeout: () => throw TimeoutException( + 'Download timed out after ${downloadTimeout.inMinutes} minutes', + downloadTimeout, + ), + ); + + var downloadComplete = false; + var downloadSuccess = false; + var dialogClosed = false; + + // Show the progress dialog that monitors download completion + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + // Listen for download completion and close dialog automatically + downloadFuture + .then((result) { + if (!downloadComplete && !dialogClosed && context.mounted) { + downloadComplete = true; + downloadSuccess = result is DownloadResultSuccess; + + // Close the dialog with the result + dialogClosed = true; + Navigator.of(context).pop(downloadSuccess); + } + }) + .catchError((Object e, StackTrace? stackTrace) { + if (!downloadComplete && !dialogClosed && context.mounted) { + downloadComplete = true; + downloadSuccess = false; + + debugPrint('Zcash parameters download failed: $e'); + if (stackTrace != null) { + debugPrint('Stack trace: $stackTrace'); + } + + // Indicate download failed (null result) + dialogClosed = true; + Navigator.of(context).pop(); + } + }); + + return AlertDialog( + title: const Text('Downloading Zcash Parameters'), + content: SizedBox( + height: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + StreamBuilder( + stream: downloader.downloadProgress, + builder: (context, snapshot) { + if (snapshot.hasData) { + final progress = snapshot.data; + return Column( + children: [ + Text( + progress?.displayText ?? '', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: (progress?.percentage ?? 0) / 100, + ), + Text( + '${(progress?.percentage ?? 0).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ); + } + return const Text('Preparing download...'); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () async { + if (!dialogClosed) { + dialogClosed = true; + await downloader.cancelDownload(); + Navigator.of(context).pop(false); // Cancelled + } + }, + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); +} diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart new file mode 100644 index 0000000000..5c56b7c108 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart' + show confirmZhtlcConfiguration; + +/// Widget that handles ZHTLC configuration dialogs automatically +/// by listening to ArrrActivationService for configuration requests +class ZhtlcConfigurationHandler extends StatefulWidget { + const ZhtlcConfigurationHandler({super.key, required this.child}); + + final Widget child; + + @override + State createState() => + _ZhtlcConfigurationHandlerState(); +} + +class _ZhtlcConfigurationHandlerState extends State { + late StreamSubscription _configRequestSubscription; + final ArrrActivationService _arrrActivationService = + GetIt.I(); + final Logger _log = Logger('ZhtlcConfigurationHandler'); + + @override + void initState() { + super.initState(); + _listenToConfigurationRequests(); + } + + @override + void dispose() { + _configRequestSubscription.cancel(); + super.dispose(); + } + + void _listenToConfigurationRequests() { + // Listen to configuration requests from the ArrrActivationService + _log.info('Setting up configuration request listener'); + _configRequestSubscription = _arrrActivationService.configurationRequests + .listen( + (configRequest) { + _log.info( + 'Received config request for ${configRequest.asset.id.id}', + ); + if (mounted && + !_handlingConfigurations.contains(configRequest.asset.id.id)) { + _log.info( + 'Showing configuration dialog for ${configRequest.asset.id.id}', + ); + _showConfigurationDialog(context, configRequest); + } else { + _log.warning( + 'Skipping config request for ${configRequest.asset.id.id} ' + '(mounted: $mounted, already handling: ${_handlingConfigurations.contains(configRequest.asset.id.id)})', + ); + } + }, + onError: (error, stackTrace) { + _log.severe( + 'Error in configuration request stream', + error, + stackTrace, + ); + }, + onDone: () { + _log.warning('Configuration request stream closed unexpectedly'); + }, + ); + } + + // Track which configuration requests are already being handled to prevent duplicates + final Set _handlingConfigurations = {}; + + @override + Widget build(BuildContext context) { + return widget.child; + } + + Future _showConfigurationDialog( + BuildContext context, + ZhtlcConfigurationRequest configRequest, + ) async { + _handlingConfigurations.add(configRequest.asset.id.id); + _log.info('Starting configuration dialog for ${configRequest.asset.id.id}'); + + try { + if (!mounted || !context.mounted) { + _log.warning( + 'Context not mounted, cancelling configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + return; + } + + final config = await confirmZhtlcConfiguration( + context, + asset: configRequest.asset, + ); + + if (config != null) { + _log.info( + 'User provided configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.submitConfiguration( + configRequest.asset.id, + config, + ); + } else { + _log.info( + 'User cancelled configuration for ${configRequest.asset.id.id}', + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + } + } catch (e, stackTrace) { + _log.severe( + 'Error in configuration dialog for ${configRequest.asset.id.id}', + e, + stackTrace, + ); + _arrrActivationService.cancelConfiguration(configRequest.asset.id); + } finally { + _handlingConfigurations.remove(configRequest.asset.id.id); + _log.info( + 'Finished handling configuration for ${configRequest.asset.id.id}', + ); + } + } + + /// Check if the configuration request listener is active + bool get isListeningToConfigurationRequests => + !_configRequestSubscription.isPaused; +} diff --git a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart index 7e53464061..18d4303b35 100644 --- a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart +++ b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart @@ -13,12 +13,14 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_utils.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_addresses.dart'; import 'package:web_dex/views/wallet/common/address_copy_button.dart'; import 'package:web_dex/views/wallet/common/address_icon.dart'; import 'package:web_dex/views/wallet/common/address_text.dart'; import 'package:web_dex/views/wallet/wallet_page/common/expandable_coin_list_item.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart'; class ActiveCoinsList extends StatelessWidget { const ActiveCoinsList({ @@ -26,11 +28,13 @@ class ActiveCoinsList extends StatelessWidget { required this.searchPhrase, required this.withBalance, required this.onCoinItemTap, + this.arrrActivationService, }); final String searchPhrase; final bool withBalance; final Function(Coin) onCoinItemTap; + final ArrrActivationService? arrrActivationService; @override Widget build(BuildContext context) { @@ -62,29 +66,44 @@ class ActiveCoinsList extends StatelessWidget { sorted = removeTestCoins(sorted); } - return SliverList.builder( - itemCount: sorted.length, - itemBuilder: (context, index) { - final coin = sorted[index]; - - // Fetch pubkeys if not already loaded - if (!state.pubkeys.containsKey(coin.abbr)) { - // TODO: Investigate if this is causing performance issues - context.read().add(CoinsPubkeysRequested(coin.abbr)); - } - - return Padding( - padding: EdgeInsets.only(bottom: 10), - child: ExpandableCoinListItem( - // Changed from ExpandableCoinListItem - key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), - coin: coin, - pubkeys: state.pubkeys[coin.abbr], - isSelected: false, - onTap: () => onCoinItemTap(coin), + return SliverMainAxisGroup( + slivers: [ + // ZHTLC Activation Status Bar + if (arrrActivationService != null) + SliverToBoxAdapter( + child: ZhtlcActivationStatusBar( + activationService: arrrActivationService!, + ), ), - ); - }, + + // Coin List + SliverList.builder( + itemCount: sorted.length, + itemBuilder: (context, index) { + final coin = sorted[index]; + + // Fetch pubkeys if not already loaded + if (!state.pubkeys.containsKey(coin.abbr)) { + // TODO: Investigate if this is causing performance issues + context.read().add( + CoinsPubkeysRequested(coin.abbr), + ); + } + + return Padding( + padding: EdgeInsets.only(bottom: 10), + child: ExpandableCoinListItem( + // Changed from ExpandableCoinListItem + key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), + coin: coin, + pubkeys: state.pubkeys[coin.abbr], + isSelected: false, + onTap: () => onCoinItemTap(coin), + ), + ); + }, + ), + ], ); }, ); diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index 9c4f375e7e..3c63be33a5 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; @@ -34,6 +35,7 @@ import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/router/state/routing_state.dart'; import 'package:web_dex/router/state/wallet_state.dart'; +import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/common/page_header/page_header.dart'; import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/dex_helpers.dart'; @@ -41,6 +43,8 @@ import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portf import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/charts/coin_prices_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/common/assets_list.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart' + show ZhtlcConfigurationHandler; import 'package:web_dex/views/wallet/wallet_page/wallet_main/active_coins_list.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_overview.dart'; @@ -129,65 +133,70 @@ class _WalletMainState extends State with TickerProviderStateMixin { : AuthorizeMode.logIn; final isLoggedIn = authStateMode == AuthorizeMode.logIn; - return BlocBuilder( - builder: (context, state) { - final walletCoinsFiltered = state.walletCoins.values.toList(); - - return PageLayout( - noBackground: true, - header: (isMobile && !isLoggedIn) - ? PageHeader(title: LocaleKeys.wallet.tr()) - : null, - padding: EdgeInsets.zero, - // Removed page padding here - content: Expanded( - child: Listener( - onPointerSignal: _onPointerSignal, - child: CustomScrollView( - key: const Key('wallet-page-scroll-view'), - controller: _scrollController, - slivers: [ - // Add a SizedBox at the top of the sliver list for spacing - if (isLoggedIn) ...[ - if (!isMobile) - const SliverToBoxAdapter(child: SizedBox(height: 32)), - SliverToBoxAdapter( - child: WalletOverview( - key: const Key('wallet-overview'), - onPortfolioGrowthPressed: () => - _tabController.animateTo(1), - onPortfolioProfitLossPressed: () => - _tabController.animateTo(2), - onAssetsPressed: () => _tabController.animateTo(0), + return ZhtlcConfigurationHandler( + child: BlocBuilder( + builder: (context, state) { + final walletCoinsFiltered = state.walletCoins.values.toList(); + + return PageLayout( + noBackground: true, + header: (isMobile && !isLoggedIn) + ? PageHeader(title: LocaleKeys.wallet.tr()) + : null, + padding: EdgeInsets.zero, + // Removed page padding here + content: Expanded( + child: Listener( + onPointerSignal: _onPointerSignal, + child: CustomScrollView( + key: const Key('wallet-page-scroll-view'), + controller: _scrollController, + slivers: [ + // Add a SizedBox at the top of the sliver list for spacing + if (isLoggedIn) ...[ + if (!isMobile) + const SliverToBoxAdapter( + child: SizedBox(height: 32), + ), + SliverToBoxAdapter( + child: WalletOverview( + key: const Key('wallet-overview'), + onPortfolioGrowthPressed: () => + _tabController.animateTo(1), + onPortfolioProfitLossPressed: () => + _tabController.animateTo(2), + onAssetsPressed: () => + _tabController.animateTo(0), + ), ), - ), - const SliverToBoxAdapter(child: Gap(24)), - ], - SliverPersistentHeader( - pinned: true, - delegate: _SliverTabBarDelegate( - TabBar( - controller: _tabController, - tabs: [ - Tab(text: LocaleKeys.assets.tr()), - if (isLoggedIn) - Tab(text: LocaleKeys.portfolioGrowth.tr()) - else - Tab(text: LocaleKeys.statistics.tr()), - if (isLoggedIn) - Tab(text: LocaleKeys.profitAndLoss.tr()), - ], + const SliverToBoxAdapter(child: Gap(24)), + ], + SliverPersistentHeader( + pinned: true, + delegate: _SliverTabBarDelegate( + TabBar( + controller: _tabController, + tabs: [ + Tab(text: LocaleKeys.assets.tr()), + if (isLoggedIn) + Tab(text: LocaleKeys.portfolioGrowth.tr()) + else + Tab(text: LocaleKeys.statistics.tr()), + if (isLoggedIn) + Tab(text: LocaleKeys.profitAndLoss.tr()), + ], + ), ), ), - ), - if (!isMobile) SliverToBoxAdapter(child: Gap(24)), - ..._buildTabSlivers(authStateMode, walletCoinsFiltered), - ], + if (!isMobile) SliverToBoxAdapter(child: Gap(24)), + ..._buildTabSlivers(authStateMode, walletCoinsFiltered), + ], + ), ), ), - ), - ); - }, + ); + }, + ), ); }, ); @@ -253,9 +262,9 @@ class _WalletMainState extends State with TickerProviderStateMixin { } void _onShowCoinsWithBalanceClick(bool value) { - context - .read() - .add(HideZeroBalanceAssetsChanged(hideZeroBalanceAssets: value)); + context.read().add( + HideZeroBalanceAssetsChanged(hideZeroBalanceAssets: value), + ); } void _onSearchChange(String searchKey) { @@ -276,11 +285,8 @@ class _WalletMainState extends State with TickerProviderStateMixin { void _onAssetStatisticsTap(AssetId assetId, Duration period) { context.read().add( - PriceChartStarted( - symbols: [assetId.symbol.configSymbol], - period: period, - ), - ); + PriceChartStarted(symbols: [assetId.symbol.configSymbol], period: period), + ); _tabController.animateTo(1); } @@ -291,8 +297,10 @@ class _WalletMainState extends State with TickerProviderStateMixin { SliverPersistentHeader( pinned: true, delegate: _SliverSearchBarDelegate( - withBalance: - context.watch().state.hideZeroBalanceAssets, + withBalance: context + .watch() + .state + .hideZeroBalanceAssets, onSearchChange: _onSearchChange, onWithBalanceChange: _onShowCoinsWithBalanceClick, mode: mode, @@ -302,8 +310,10 @@ class _WalletMainState extends State with TickerProviderStateMixin { CoinListView( mode: mode, searchPhrase: _searchKey, - withBalance: - context.watch().state.hideZeroBalanceAssets, + withBalance: context + .watch() + .state + .hideZeroBalanceAssets, onActiveCoinItemTap: _onActiveCoinItemTap, onAssetItemTap: _onAssetItemTap, onAssetStatisticsTap: _onAssetStatisticsTap, @@ -345,11 +355,11 @@ class _WalletMainState extends State with TickerProviderStateMixin { _walletHalfLogged = true; final coinsCount = context.read().state.walletCoins.length; context.read().logEvent( - WalletListHalfViewportReachedEventData( - timeToHalfMs: _walletListStopwatch.elapsedMilliseconds, - walletSize: coinsCount, - ), - ); + WalletListHalfViewportReachedEventData( + timeToHalfMs: _walletListStopwatch.elapsedMilliseconds, + walletSize: coinsCount, + ), + ); } } @@ -362,11 +372,11 @@ class _WalletMainState extends State with TickerProviderStateMixin { if (newOffset == _scrollController.offset) { context.read().logEvent( - ScrollAttemptOutsideContentEventData( - screenContext: 'wallet_page', - scrollDelta: event.scrollDelta.dy, - ), - ); + ScrollAttemptOutsideContentEventData( + screenContext: 'wallet_page', + scrollDelta: event.scrollDelta.dy, + ), + ); return; } @@ -421,6 +431,7 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, withBalance: withBalance, onCoinItemTap: onActiveCoinItemTap, + arrrActivationService: GetIt.I(), ); case AuthorizeMode.hiddenLogin: case AuthorizeMode.noLogin: @@ -437,8 +448,8 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, onAssetItemTap: (assetId) => onAssetItemTap( context.read().state.coins.values.firstWhere( - (coin) => coin.assetId == assetId, - ), + (coin) => coin.assetId == assetId, + ), ), onStatisticsTap: onAssetStatisticsTap, ); @@ -471,8 +482,10 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { bool overlapsContent, ) { // Apply collapse progress on both mobile and desktop - final collapseProgress = - (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0); + final collapseProgress = (shrinkOffset / (maxExtent - minExtent)).clamp( + 0.0, + 1.0, + ); return SizedBox( height: (maxExtent - shrinkOffset).clamp(minExtent, maxExtent), From 349baca6ebd36873c578501ce4a9541f131aa75b Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 26 Sep 2025 19:03:07 +0200 Subject: [PATCH 04/24] refactor: generate localisations, and fix theming on popup dialog --- assets/translations/en.json | 18 +- lib/generated/codegen_loader.g.dart | 161 ++--- .../arrr_activation_service.dart | 53 +- .../zhtlc/zhtlc_configuration_dialog.dart | 678 +++++++++++------- 4 files changed, 536 insertions(+), 374 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 10889f0bf6..001a5af02c 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -357,7 +357,6 @@ "backupSeedPhrase": "Backup seed phrase", "seedOr": "OR", "seedDownload": "Download seed phrase", - "seedSaveAndRemember": "Save and remember", "seedIntroWarning": "This phrase is the main access to your\nassets, save and never share this phrase", "seedSettings": "Seed phrase", "errorDescription": "Error description", @@ -762,6 +761,23 @@ "fetchingPrivateKeysMessage": "Please wait while we securely fetch your private keys...", "pubkeyType": "Type", "securitySettings": "Security Settings", + "zhtlcConfigureTitle": "Configure {}", + "zhtlcZcashParamsPathLabel": "Zcash parameters path", + "zhtlcPathAutomaticallyDetected": "Path automatically detected", + "zhtlcSaplingParamsFolder": "Folder containing sapling params", + "zhtlcBlocksPerIterationLabel": "Blocks per iteration", + "zhtlcScanIntervalLabel": "Scan interval (ms)", + "zhtlcStartSyncFromLabel": "Start sync from:", + "zhtlcEarliestSaplingOption": "Earliest (sapling)", + "zhtlcBlockHeightOption": "Block height", + "zhtlcDateTimeOption": "Date & Time", + "zhtlcSelectDateTimeLabel": "Select date & time", + "zhtlcZcashParamsRequired": "Zcash params path is required", + "zhtlcInvalidBlockHeight": "Enter a valid block height", + "zhtlcSelectDateTimeRequired": "Please select a date and time", + "zhtlcDownloadingZcashParams": "Downloading Zcash Parameters", + "zhtlcPreparingDownload": "Preparing download...", + "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", "activatingZhtlcCoins": { "one": "Activating ZHTLC coin: {}", "other": "Activating ZHTLC coins: {}" diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 60cb50aad5..7d0d4cf2f3 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -2,7 +2,7 @@ // ignore_for_file: constant_identifier_names -abstract class LocaleKeys { +abstract class LocaleKeys { static const plsActivateKmd = 'plsActivateKmd'; static const rewardClaiming = 'rewardClaiming'; static const noKmdAddress = 'noKmdAddress'; @@ -106,34 +106,27 @@ abstract class LocaleKeys { static const seedPhrase = 'seedPhrase'; static const assetNumber = 'assetNumber'; static const clipBoard = 'clipBoard'; - static const walletsManagerCreateWalletButton = - 'walletsManagerCreateWalletButton'; - static const walletsManagerImportWalletButton = - 'walletsManagerImportWalletButton'; - static const walletsManagerStepBuilderCreationWalletError = - 'walletsManagerStepBuilderCreationWalletError'; + static const walletsManagerCreateWalletButton = 'walletsManagerCreateWalletButton'; + static const walletsManagerImportWalletButton = 'walletsManagerImportWalletButton'; + static const walletsManagerStepBuilderCreationWalletError = 'walletsManagerStepBuilderCreationWalletError'; static const walletCreationTitle = 'walletCreationTitle'; static const walletImportTitle = 'walletImportTitle'; static const walletImportByFileTitle = 'walletImportByFileTitle'; static const invalidWalletNameError = 'invalidWalletNameError'; static const invalidWalletFileNameError = 'invalidWalletFileNameError'; - static const walletImportCreatePasswordTitle = - 'walletImportCreatePasswordTitle'; + static const walletImportCreatePasswordTitle = 'walletImportCreatePasswordTitle'; static const walletImportByFileDescription = 'walletImportByFileDescription'; static const walletLogInTitle = 'walletLogInTitle'; static const walletCreationNameHint = 'walletCreationNameHint'; static const walletCreationPasswordHint = 'walletCreationPasswordHint'; - static const walletCreationConfirmPasswordHint = - 'walletCreationConfirmPasswordHint'; + static const walletCreationConfirmPasswordHint = 'walletCreationConfirmPasswordHint'; static const walletCreationConfirmPassword = 'walletCreationConfirmPassword'; static const walletCreationUploadFile = 'walletCreationUploadFile'; static const walletCreationEmptySeedError = 'walletCreationEmptySeedError'; static const walletCreationExistNameError = 'walletCreationExistNameError'; static const walletCreationNameLengthError = 'walletCreationNameLengthError'; - static const walletCreationFormatPasswordError = - 'walletCreationFormatPasswordError'; - static const walletCreationConfirmPasswordError = - 'walletCreationConfirmPasswordError'; + static const walletCreationFormatPasswordError = 'walletCreationFormatPasswordError'; + static const walletCreationConfirmPasswordError = 'walletCreationConfirmPasswordError'; static const incorrectPassword = 'incorrectPassword'; static const oneClickLogin = 'oneClickLogin'; static const quickLoginTooltip = 'quickLoginTooltip'; @@ -142,19 +135,15 @@ abstract class LocaleKeys { static const passphraseCheckingTitle = 'passphraseCheckingTitle'; static const passphraseCheckingDescription = 'passphraseCheckingDescription'; static const passphraseCheckingEnterWord = 'passphraseCheckingEnterWord'; - static const passphraseCheckingEnterWordHint = - 'passphraseCheckingEnterWordHint'; + static const passphraseCheckingEnterWordHint = 'passphraseCheckingEnterWordHint'; static const back = 'back'; static const settingsMenuGeneral = 'settingsMenuGeneral'; static const settingsMenuLanguage = 'settingsMenuLanguage'; static const settingsMenuSecurity = 'settingsMenuSecurity'; static const settingsMenuAbout = 'settingsMenuAbout'; - static const seedPhraseSettingControlsViewSeed = - 'seedPhraseSettingControlsViewSeed'; - static const seedPhraseSettingControlsDownloadSeed = - 'seedPhraseSettingControlsDownloadSeed'; - static const debugSettingsResetActivatedCoins = - 'debugSettingsResetActivatedCoins'; + static const seedPhraseSettingControlsViewSeed = 'seedPhraseSettingControlsViewSeed'; + static const seedPhraseSettingControlsDownloadSeed = 'seedPhraseSettingControlsDownloadSeed'; + static const debugSettingsResetActivatedCoins = 'debugSettingsResetActivatedCoins'; static const debugSettingsDownloadButton = 'debugSettingsDownloadButton'; static const or = 'or'; static const passwordTitle = 'passwordTitle'; @@ -164,19 +153,16 @@ abstract class LocaleKeys { static const changePasswordSpan1 = 'changePasswordSpan1'; static const updatePassword = 'updatePassword'; static const passwordHasChanged = 'passwordHasChanged'; - static const confirmationForShowingSeedPhraseTitle = - 'confirmationForShowingSeedPhraseTitle'; + static const confirmationForShowingSeedPhraseTitle = 'confirmationForShowingSeedPhraseTitle'; static const saveAndRemember = 'saveAndRemember'; static const seedPhraseShowingTitle = 'seedPhraseShowingTitle'; static const seedPhraseShowingWarning = 'seedPhraseShowingWarning'; static const seedPhraseShowingShowPhrase = 'seedPhraseShowingShowPhrase'; static const seedPhraseShowingCopySeed = 'seedPhraseShowingCopySeed'; - static const seedPhraseShowingSavedPhraseButton = - 'seedPhraseShowingSavedPhraseButton'; + static const seedPhraseShowingSavedPhraseButton = 'seedPhraseShowingSavedPhraseButton'; static const seedAccessSpan1 = 'seedAccessSpan1'; static const backupSeedNotificationTitle = 'backupSeedNotificationTitle'; - static const backupSeedNotificationDescription = - 'backupSeedNotificationDescription'; + static const backupSeedNotificationDescription = 'backupSeedNotificationDescription'; static const backupSeedNotificationButton = 'backupSeedNotificationButton'; static const swapConfirmationTitle = 'swapConfirmationTitle'; static const swapConfirmationYouReceive = 'swapConfirmationYouReceive'; @@ -184,54 +170,41 @@ abstract class LocaleKeys { static const tradingDetailsTitleFailed = 'tradingDetailsTitleFailed'; static const tradingDetailsTitleCompleted = 'tradingDetailsTitleCompleted'; static const tradingDetailsTitleInProgress = 'tradingDetailsTitleInProgress'; - static const tradingDetailsTitleOrderMatching = - 'tradingDetailsTitleOrderMatching'; + static const tradingDetailsTitleOrderMatching = 'tradingDetailsTitleOrderMatching'; static const tradingDetailsTotalSpentTime = 'tradingDetailsTotalSpentTime'; - static const tradingDetailsTotalSpentTimeWithHours = - 'tradingDetailsTotalSpentTimeWithHours'; + static const tradingDetailsTotalSpentTimeWithHours = 'tradingDetailsTotalSpentTimeWithHours'; static const swapRecoverButtonTitle = 'swapRecoverButtonTitle'; static const swapRecoverButtonText = 'swapRecoverButtonText'; static const swapRecoverButtonErrorMessage = 'swapRecoverButtonErrorMessage'; - static const swapRecoverButtonSuccessMessage = - 'swapRecoverButtonSuccessMessage'; + static const swapRecoverButtonSuccessMessage = 'swapRecoverButtonSuccessMessage'; static const swapProgressStatusFailed = 'swapProgressStatusFailed'; static const swapDetailsStepStatusFailed = 'swapDetailsStepStatusFailed'; static const disclaimerAcceptEulaCheckbox = 'disclaimerAcceptEulaCheckbox'; - static const disclaimerAcceptTermsAndConditionsCheckbox = - 'disclaimerAcceptTermsAndConditionsCheckbox'; + static const disclaimerAcceptTermsAndConditionsCheckbox = 'disclaimerAcceptTermsAndConditionsCheckbox'; static const disclaimerAcceptDescription = 'disclaimerAcceptDescription'; - static const swapDetailsStepStatusInProcess = - 'swapDetailsStepStatusInProcess'; - static const swapDetailsStepStatusTimeSpent = - 'swapDetailsStepStatusTimeSpent'; + static const swapDetailsStepStatusInProcess = 'swapDetailsStepStatusInProcess'; + static const swapDetailsStepStatusTimeSpent = 'swapDetailsStepStatusTimeSpent'; static const milliseconds = 'milliseconds'; static const seconds = 'seconds'; static const minutes = 'minutes'; static const hours = 'hours'; - static const coinAddressDetailsNotificationTitle = - 'coinAddressDetailsNotificationTitle'; - static const coinAddressDetailsNotificationDescription = - 'coinAddressDetailsNotificationDescription'; + static const coinAddressDetailsNotificationTitle = 'coinAddressDetailsNotificationTitle'; + static const coinAddressDetailsNotificationDescription = 'coinAddressDetailsNotificationDescription'; static const swapFeeDetailsPaidFromBalance = 'swapFeeDetailsPaidFromBalance'; static const swapFeeDetailsSendCoinTxFee = 'swapFeeDetailsSendCoinTxFee'; - static const swapFeeDetailsReceiveCoinTxFee = - 'swapFeeDetailsReceiveCoinTxFee'; + static const swapFeeDetailsReceiveCoinTxFee = 'swapFeeDetailsReceiveCoinTxFee'; static const swapFeeDetailsTradingFee = 'swapFeeDetailsTradingFee'; - static const swapFeeDetailsSendTradingFeeTxFee = - 'swapFeeDetailsSendTradingFeeTxFee'; + static const swapFeeDetailsSendTradingFeeTxFee = 'swapFeeDetailsSendTradingFeeTxFee'; static const swapFeeDetailsNone = 'swapFeeDetailsNone'; - static const swapFeeDetailsPaidFromReceivedVolume = - 'swapFeeDetailsPaidFromReceivedVolume'; + static const swapFeeDetailsPaidFromReceivedVolume = 'swapFeeDetailsPaidFromReceivedVolume'; static const logoutPopupTitle = 'logoutPopupTitle'; - static const logoutPopupDescriptionWalletOnly = - 'logoutPopupDescriptionWalletOnly'; + static const logoutPopupDescriptionWalletOnly = 'logoutPopupDescriptionWalletOnly'; static const logoutPopupDescription = 'logoutPopupDescription'; static const transactionDetailsTitle = 'transactionDetailsTitle'; static const customSeedWarningText = 'customSeedWarningText'; static const customSeedIUnderstand = 'customSeedIUnderstand'; static const walletCreationBip39SeedError = 'walletCreationBip39SeedError'; - static const walletCreationHdBip39SeedError = - 'walletCreationHdBip39SeedError'; + static const walletCreationHdBip39SeedError = 'walletCreationHdBip39SeedError'; static const walletPageNoSuchAsset = 'walletPageNoSuchAsset'; static const swap = 'swap'; static const dexAddress = 'dexAddress'; @@ -307,8 +280,7 @@ abstract class LocaleKeys { static const sellCryptoDescription = 'sellCryptoDescription'; static const buy = 'buy'; static const changingWalletPassword = 'changingWalletPassword'; - static const changingWalletPasswordDescription = - 'changingWalletPasswordDescription'; + static const changingWalletPasswordDescription = 'changingWalletPasswordDescription'; static const dark = 'dark'; static const darkMode = 'darkMode'; static const light = 'light'; @@ -340,8 +312,7 @@ abstract class LocaleKeys { static const feedbackFormDiscord = 'feedbackFormDiscord'; static const feedbackFormMatrix = 'feedbackFormMatrix'; static const feedbackFormTelegram = 'feedbackFormTelegram'; - static const feedbackFormSelectContactMethod = - 'feedbackFormSelectContactMethod'; + static const feedbackFormSelectContactMethod = 'feedbackFormSelectContactMethod'; static const feedbackFormDiscordHint = 'feedbackFormDiscordHint'; static const feedbackFormMatrixHint = 'feedbackFormMatrixHint'; static const feedbackFormTelegramHint = 'feedbackFormTelegramHint'; @@ -353,8 +324,7 @@ abstract class LocaleKeys { static const contactRequiredError = 'contactRequiredError'; static const contactDetailsMaxLengthError = 'contactDetailsMaxLengthError'; static const discordUsernameValidatorError = 'discordUsernameValidatorError'; - static const telegramUsernameValidatorError = - 'telegramUsernameValidatorError'; + static const telegramUsernameValidatorError = 'telegramUsernameValidatorError'; static const matrixIdValidatorError = 'matrixIdValidatorError'; static const myCoinsMissing = 'myCoinsMissing'; static const myCoinsMissingReassurance = 'myCoinsMissingReassurance'; @@ -364,8 +334,7 @@ abstract class LocaleKeys { static const myCoinsMissingHelp = 'myCoinsMissingHelp'; static const myCoinsMissingSignIn = 'myCoinsMissingSignIn'; static const feedbackValidatorEmptyError = 'feedbackValidatorEmptyError'; - static const feedbackValidatorMaxLengthError = - 'feedbackValidatorMaxLengthError'; + static const feedbackValidatorMaxLengthError = 'feedbackValidatorMaxLengthError'; static const yourFeedback = 'yourFeedback'; static const sendFeedback = 'sendFeedback'; static const sendFeedbackError = 'sendFeedbackError'; @@ -385,7 +354,6 @@ abstract class LocaleKeys { static const backupSeedPhrase = 'backupSeedPhrase'; static const seedOr = 'seedOr'; static const seedDownload = 'seedDownload'; - static const seedSaveAndRemember = 'seedSaveAndRemember'; static const seedIntroWarning = 'seedIntroWarning'; static const seedSettings = 'seedSettings'; static const errorDescription = 'errorDescription'; @@ -405,8 +373,7 @@ abstract class LocaleKeys { static const trezorSelectTitle = 'trezorSelectTitle'; static const trezorSelectSubTitle = 'trezorSelectSubTitle'; static const trezorBrowserUnsupported = 'trezorBrowserUnsupported'; - static const trezorTransactionInProgressMessage = - 'trezorTransactionInProgressMessage'; + static const trezorTransactionInProgressMessage = 'trezorTransactionInProgressMessage'; static const mixedCaseError = 'mixedCaseError'; static const addressConvertedToMixedCase = 'addressConvertedToMixedCase'; static const invalidAddressChecksum = 'invalidAddressChecksum'; @@ -416,8 +383,7 @@ abstract class LocaleKeys { static const noSenderAddress = 'noSenderAddress'; static const confirmOnTrezor = 'confirmOnTrezor'; static const alphaVersionWarningTitle = 'alphaVersionWarningTitle'; - static const alphaVersionWarningDescription = - 'alphaVersionWarningDescription'; + static const alphaVersionWarningDescription = 'alphaVersionWarningDescription'; static const sendToAnalytics = 'sendToAnalytics'; static const backToWallet = 'backToWallet'; static const backToDex = 'backToDex'; @@ -442,14 +408,12 @@ abstract class LocaleKeys { static const currentPassword = 'currentPassword'; static const walletNotFound = 'walletNotFound'; static const passwordIsEmpty = 'passwordIsEmpty'; - static const passwordContainsTheWordPassword = - 'passwordContainsTheWordPassword'; + static const passwordContainsTheWordPassword = 'passwordContainsTheWordPassword'; static const passwordTooShort = 'passwordTooShort'; static const passwordMissingDigit = 'passwordMissingDigit'; static const passwordMissingLowercase = 'passwordMissingLowercase'; static const passwordMissingUppercase = 'passwordMissingUppercase'; - static const passwordMissingSpecialCharacter = - 'passwordMissingSpecialCharacter'; + static const passwordMissingSpecialCharacter = 'passwordMissingSpecialCharacter'; static const passwordConsecutiveCharacters = 'passwordConsecutiveCharacters'; static const passwordSecurity = 'passwordSecurity'; static const allowWeakPassword = 'allowWeakPassword'; @@ -478,16 +442,13 @@ abstract class LocaleKeys { static const bridgeMaxSendAmountError = 'bridgeMaxSendAmountError'; static const bridgeMinOrderAmountError = 'bridgeMinOrderAmountError'; static const bridgeMaxOrderAmountError = 'bridgeMaxOrderAmountError'; - static const bridgeInsufficientBalanceError = - 'bridgeInsufficientBalanceError'; + static const bridgeInsufficientBalanceError = 'bridgeInsufficientBalanceError'; static const lowTradeVolumeError = 'lowTradeVolumeError'; static const bridgeSelectReceiveCoinError = 'bridgeSelectReceiveCoinError'; static const withdrawNoParentCoinError = 'withdrawNoParentCoinError'; static const withdrawTopUpBalanceError = 'withdrawTopUpBalanceError'; - static const withdrawNotEnoughBalanceForGasError = - 'withdrawNotEnoughBalanceForGasError'; - static const withdrawNotSufficientBalanceError = - 'withdrawNotSufficientBalanceError'; + static const withdrawNotEnoughBalanceForGasError = 'withdrawNotEnoughBalanceForGasError'; + static const withdrawNotSufficientBalanceError = 'withdrawNotSufficientBalanceError'; static const withdrawZeroBalanceError = 'withdrawZeroBalanceError'; static const withdrawAmountTooLowError = 'withdrawAmountTooLowError'; static const withdrawNoSuchCoinError = 'withdrawNoSuchCoinError'; @@ -559,10 +520,8 @@ abstract class LocaleKeys { static const availableForSwaps = 'availableForSwaps'; static const swapNow = 'swapNow'; static const passphrase = 'passphrase'; - static const enterPassphraseHiddenWalletTitle = - 'enterPassphraseHiddenWalletTitle'; - static const enterPassphraseHiddenWalletDescription = - 'enterPassphraseHiddenWalletDescription'; + static const enterPassphraseHiddenWalletTitle = 'enterPassphraseHiddenWalletTitle'; + static const enterPassphraseHiddenWalletDescription = 'enterPassphraseHiddenWalletDescription'; static const skip = 'skip'; static const activateToSeeFunds = 'activateToSeeFunds'; static const useCustomSeedOrWif = 'useCustomSeedOrWif'; @@ -589,15 +548,13 @@ abstract class LocaleKeys { static const downloadAllKeys = 'downloadAllKeys'; static const shareAllKeys = 'shareAllKeys'; static const confirmPrivateKeyBackup = 'confirmPrivateKeyBackup'; - static const confirmPrivateKeyBackupDescription = - 'confirmPrivateKeyBackupDescription'; + static const confirmPrivateKeyBackupDescription = 'confirmPrivateKeyBackupDescription'; static const importantSecurityNotice = 'importantSecurityNotice'; static const privateKeySecurityWarning = 'privateKeySecurityWarning'; static const privateKeyBackupConfirmation = 'privateKeyBackupConfirmation'; static const confirmBackupComplete = 'confirmBackupComplete'; static const privateKeyExportSuccessTitle = 'privateKeyExportSuccessTitle'; - static const privateKeyExportSuccessDescription = - 'privateKeyExportSuccessDescription'; + static const privateKeyExportSuccessDescription = 'privateKeyExportSuccessDescription'; static const iHaveSavedMyPrivateKeys = 'iHaveSavedMyPrivateKeys'; static const copyWarning = 'copyWarning'; static const seedConfirmTitle = 'seedConfirmTitle'; @@ -645,10 +602,8 @@ abstract class LocaleKeys { static const collectibles = 'collectibles'; static const sendingProcess = 'sendingProcess'; static const ercStandardDisclaimer = 'ercStandardDisclaimer'; - static const nftReceiveNonSwapAddressWarning = - 'nftReceiveNonSwapAddressWarning'; - static const nftReceiveNonSwapWalletDetails = - 'nftReceiveNonSwapWalletDetails'; + static const nftReceiveNonSwapAddressWarning = 'nftReceiveNonSwapAddressWarning'; + static const nftReceiveNonSwapWalletDetails = 'nftReceiveNonSwapWalletDetails'; static const nftMainLoggedOut = 'nftMainLoggedOut'; static const confirmLogoutOnAnotherTab = 'confirmLogoutOnAnotherTab'; static const refreshList = 'refreshList'; @@ -662,10 +617,8 @@ abstract class LocaleKeys { static const noWalletsAvailable = 'noWalletsAvailable'; static const selectWalletToReset = 'selectWalletToReset'; static const qrScannerTitle = 'qrScannerTitle'; - static const qrScannerErrorControllerUninitialized = - 'qrScannerErrorControllerUninitialized'; - static const qrScannerErrorPermissionDenied = - 'qrScannerErrorPermissionDenied'; + static const qrScannerErrorControllerUninitialized = 'qrScannerErrorControllerUninitialized'; + static const qrScannerErrorPermissionDenied = 'qrScannerErrorPermissionDenied'; static const qrScannerErrorGenericError = 'qrScannerErrorGenericError'; static const qrScannerErrorTitle = 'qrScannerErrorTitle'; static const spend = 'spend'; @@ -710,8 +663,7 @@ abstract class LocaleKeys { static const fiatPaymentInProgressMessage = 'fiatPaymentInProgressMessage'; static const pleaseWait = 'pleaseWait'; static const bitrefillPaymentSuccessfull = 'bitrefillPaymentSuccessfull'; - static const bitrefillPaymentSuccessfullInstruction = - 'bitrefillPaymentSuccessfullInstruction'; + static const bitrefillPaymentSuccessfullInstruction = 'bitrefillPaymentSuccessfullInstruction'; static const tradingBot = 'tradingBot'; static const margin = 'margin'; static const updateInterval = 'updateInterval'; @@ -799,6 +751,23 @@ abstract class LocaleKeys { static const fetchingPrivateKeysMessage = 'fetchingPrivateKeysMessage'; static const pubkeyType = 'pubkeyType'; static const securitySettings = 'securitySettings'; + static const zhtlcConfigureTitle = 'zhtlcConfigureTitle'; + static const zhtlcZcashParamsPathLabel = 'zhtlcZcashParamsPathLabel'; + static const zhtlcPathAutomaticallyDetected = 'zhtlcPathAutomaticallyDetected'; + static const zhtlcSaplingParamsFolder = 'zhtlcSaplingParamsFolder'; + static const zhtlcBlocksPerIterationLabel = 'zhtlcBlocksPerIterationLabel'; + static const zhtlcScanIntervalLabel = 'zhtlcScanIntervalLabel'; + static const zhtlcStartSyncFromLabel = 'zhtlcStartSyncFromLabel'; + static const zhtlcEarliestSaplingOption = 'zhtlcEarliestSaplingOption'; + static const zhtlcBlockHeightOption = 'zhtlcBlockHeightOption'; + static const zhtlcDateTimeOption = 'zhtlcDateTimeOption'; + static const zhtlcSelectDateTimeLabel = 'zhtlcSelectDateTimeLabel'; + static const zhtlcZcashParamsRequired = 'zhtlcZcashParamsRequired'; + static const zhtlcInvalidBlockHeight = 'zhtlcInvalidBlockHeight'; + static const zhtlcSelectDateTimeRequired = 'zhtlcSelectDateTimeRequired'; + static const zhtlcDownloadingZcashParams = 'zhtlcDownloadingZcashParams'; + static const zhtlcPreparingDownload = 'zhtlcPreparingDownload'; + static const zhtlcErrorSettingUpZcash = 'zhtlcErrorSettingUpZcash'; static const activatingZhtlcCoins = 'activatingZhtlcCoins'; } diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index 82af0e8d5b..b6e1b07873 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -10,7 +10,9 @@ import 'arrr_config.dart'; /// Service layer - business logic coordination for ARRR activation class ArrrActivationService { ArrrActivationService(this._sdk) - : _configService = _sdk.activationConfigService; + : _configService = _sdk.activationConfigService { + _startListeningToAuthChanges(); + } final ActivationConfigService _configService; final KomodoDefiSdk _sdk; @@ -23,6 +25,9 @@ class ArrrActivationService { /// Completer to wait for configuration when needed final Map> _configCompleters = {}; + /// Subscription to auth state changes + StreamSubscription? _authSubscription; + /// Stream of configuration requests that UI can listen to Stream get configurationRequests => _configRequestController.stream; @@ -284,8 +289,54 @@ class ArrrActivationService { stopwatch.stop(); } + /// Start listening to authentication state changes + void _startListeningToAuthChanges() { + _authSubscription?.cancel(); + _authSubscription = _sdk.auth.watchCurrentUser().listen( + _handleAuthStateChange, + ); + } + + /// Handle authentication state changes + void _handleAuthStateChange(KdfUser? user) { + if (user == null) { + // User signed out - cleanup all active operations + _cleanupOnSignOut(); + } + } + + /// Clean up all user-specific state when user signs out + void _cleanupOnSignOut() { + _log.info('User signed out - cleaning up active ZHTLC activations'); + + // Cancel all pending configuration requests + final pendingAssets = _configCompleters.keys.toList(); + for (final assetId in pendingAssets) { + final completer = _configCompleters[assetId]; + if (completer != null && !completer.isCompleted) { + _log.info('Cancelling pending configuration request for ${assetId.id}'); + completer.complete(null); + } + } + _configCompleters.clear(); + + // Clear activation cache as it's user-specific + final activeAssets = _activationCache.keys.toList(); + for (final assetId in activeAssets) { + _log.info('Clearing activation status for ${assetId.id}'); + } + _activationCache.clear(); + + _log.info( + 'Cleanup completed - cancelled ${pendingAssets.length} pending configs and cleared ${activeAssets.length} activation statuses', + ); + } + /// Dispose resources void dispose() { + // Cancel auth subscription first + _authSubscription?.cancel(); + // Complete any pending configuration requests for (final completer in _configCompleters.values) { if (!completer.isCompleted) { diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart index 8fdd6a9f94..9d495c3476 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' @@ -13,6 +14,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' DownloadResultSuccess; import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; /// Shows ZHTLC configuration dialog similar to handleZhtlcConfigDialog from SDK example /// This is bad practice (UI logic in utils), but necessary for now because of @@ -50,7 +52,7 @@ Future confirmZhtlcConfiguration( if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error setting up Zcash parameters: $e'), + content: Text(LocaleKeys.zhtlcErrorSettingUpZcash.tr(args: ['$e'])), backgroundColor: Theme.of(context).colorScheme.error, ), ); @@ -61,207 +63,313 @@ Future confirmZhtlcConfiguration( } } - // On web, use './zcash-params' as default, otherwise use prefilledZcashPath - final defaultZcashPath = kIsWeb ? './zcash-params' : prefilledZcashPath; - final zcashPathController = TextEditingController(text: defaultZcashPath); - final blocksPerIterController = TextEditingController(text: '1000'); - final intervalMsController = TextEditingController(text: '0'); + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ZhtlcConfigurationDialog( + asset: asset, + prefilledZcashPath: prefilledZcashPath, + ), + ); +} + +/// Stateful widget for ZHTLC configuration dialog +class ZhtlcConfigurationDialog extends StatefulWidget { + const ZhtlcConfigurationDialog({ + super.key, + required this.asset, + this.prefilledZcashPath, + }); + + final Asset asset; + final String? prefilledZcashPath; - var syncType = 'date'; // earliest | height | date - final syncValueController = TextEditingController(); + @override + State createState() => + _ZhtlcConfigurationDialogState(); +} + +class _ZhtlcConfigurationDialogState extends State { + late final TextEditingController zcashPathController; + late final TextEditingController blocksPerIterController; + late final TextEditingController intervalMsController; + late final TextEditingController syncValueController; + + String syncType = 'date'; DateTime? selectedDateTime; + @override + void initState() { + super.initState(); + + // On web, use './zcash-params' as default, otherwise use prefilledZcashPath + final defaultZcashPath = kIsWeb + ? './zcash-params' + : widget.prefilledZcashPath; + zcashPathController = TextEditingController(text: defaultZcashPath); + blocksPerIterController = TextEditingController(text: '1000'); + intervalMsController = TextEditingController(text: '0'); + syncValueController = TextEditingController(); + + // Initialize with default date (2 days ago) + selectedDateTime = DateTime.now().subtract(const Duration(days: 2)); + syncValueController.text = formatDate(selectedDateTime!); + } + + @override + void dispose() { + zcashPathController.dispose(); + blocksPerIterController.dispose(); + intervalMsController.dispose(); + syncValueController.dispose(); + super.dispose(); + } + String formatDate(DateTime dateTime) { return dateTime.toIso8601String().split('T')[0]; } - Future selectDate(BuildContext context) async { + /// Creates a Material 3 theme for the date picker based on the current Material 2 theme + ThemeData _createMaterial3DatePickerTheme(BuildContext context) { + final currentTheme = Theme.of(context); + final currentColorScheme = currentTheme.colorScheme; + + // Use the current theme's primary color as the seed color + // This works for both light and dark themes since primary is set appropriately in each + final material3ColorScheme = ColorScheme.fromSeed( + seedColor: currentColorScheme.primary, + brightness: currentColorScheme.brightness, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: material3ColorScheme, + fontFamily: currentTheme.textTheme.bodyMedium?.fontFamily, + ); + } + + Future _selectDate() async { final picked = await showDatePicker( context: context, initialDate: selectedDateTime ?? DateTime.now(), firstDate: DateTime(2018), // first arrr block in 2018 lastDate: DateTime.now(), builder: (context, child) { - return child ?? const SizedBox(); + return Theme( + data: _createMaterial3DatePickerTheme(context), + child: child ?? const SizedBox(), + ); }, ); if (picked != null) { - selectedDateTime = DateTime(picked.year, picked.month, picked.day); - syncValueController.text = formatDate(selectedDateTime!); + setState(() { + selectedDateTime = DateTime(picked.year, picked.month, picked.day); + syncValueController.text = formatDate(selectedDateTime!); + }); } } - // Initialize with default date (2 days ago) - selectedDateTime = DateTime.now().subtract(const Duration(days: 2)); - syncValueController.text = formatDate(selectedDateTime!); - - ZhtlcUserConfig? result; + void _onSyncTypeChanged(String? newSyncType) { + if (newSyncType == null) return; + setState(() { + syncType = newSyncType; + // Clear the input when switching sync types + if (syncType == 'date') { + // Set default date (2 days ago) for date type + selectedDateTime = DateTime.now().subtract(const Duration(days: 2)); + syncValueController.text = formatDate(selectedDateTime!); + } else if (syncType == 'height') { + // Clear input for block height + syncValueController.clear(); + } else { + // Clear input for earliest (no input needed) + syncValueController.clear(); + } + }); + } - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - return StatefulBuilder( - builder: (context, setInnerState) { - return AlertDialog( - title: Text('Configure ${asset.id.id}'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!kIsWeb) ...[ - TextField( - controller: zcashPathController, - readOnly: prefilledZcashPath != null, - decoration: InputDecoration( - labelText: 'Zcash parameters path', - helperText: prefilledZcashPath != null - ? 'Path automatically detected' - : 'Folder containing sapling params', - ), - ), - const SizedBox(height: 12), - ], - TextField( - controller: blocksPerIterController, - decoration: const InputDecoration( - labelText: 'Blocks per iteration', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 12), - TextField( - controller: intervalMsController, - decoration: const InputDecoration( - labelText: 'Scan interval (ms)', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 12), - Row( - children: [ - const Text('Start sync from:'), - const SizedBox(width: 12), - DropdownButton( - value: syncType, - items: const [ - DropdownMenuItem( - value: 'earliest', - child: Text('Earliest (sapling)'), - ), - DropdownMenuItem( - value: 'height', - child: Text('Block height'), - ), - DropdownMenuItem( - value: 'date', - child: Text('Date & Time'), - ), - ], - onChanged: (v) { - if (v == null) return; - setInnerState(() => syncType = v); - }, - ), - const SizedBox(width: 8), - if (syncType != 'earliest') - Expanded( - child: TextField( - controller: syncValueController, - decoration: InputDecoration( - labelText: syncType == 'height' - ? 'Block height' - : 'Select date & time', - suffixIcon: syncType == 'date' - ? IconButton( - icon: const Icon(Icons.calendar_today), - onPressed: () => selectDate(context), - ) - : null, - ), - keyboardType: syncType == 'height' - ? TextInputType.number - : TextInputType.none, - readOnly: syncType == 'date', - onTap: syncType == 'date' - ? () => selectDate(context) - : null, - ), - ), - ], - ), - ], - ), + Widget _buildSyncForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(LocaleKeys.zhtlcStartSyncFromLabel.tr()), + const SizedBox(width: 12), + DropdownButton( + value: syncType, + items: [ + DropdownMenuItem( + value: 'earliest', + child: Text(LocaleKeys.zhtlcEarliestSaplingOption.tr()), + ), + DropdownMenuItem( + value: 'height', + child: Text(LocaleKeys.zhtlcBlockHeightOption.tr()), + ), + DropdownMenuItem( + value: 'date', + child: Text(LocaleKeys.zhtlcDateTimeOption.tr()), + ), + ], + onChanged: _onSyncTypeChanged, + ), + ], + ), + if (syncType != 'earliest') ...[ + const SizedBox(height: 12), + TextField( + controller: syncValueController, + decoration: InputDecoration( + labelText: syncType == 'height' + ? LocaleKeys.zhtlcBlockHeightOption.tr() + : LocaleKeys.zhtlcSelectDateTimeLabel.tr(), + suffixIcon: syncType == 'date' + ? IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate, + ) + : null, ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + keyboardType: syncType == 'height' + ? TextInputType.number + : TextInputType.none, + readOnly: syncType == 'date', + onTap: syncType == 'date' ? _selectDate : null, + ), + if (syncType == 'date') ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 4.0), + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 12.0, ), - FilledButton( - onPressed: () { - final path = zcashPathController.text.trim(); - // On web, allow empty path, otherwise require it - if (!kIsWeb && path.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Zcash params path is required'), - ), - ); - return; - } - - // Create sync params based on type - ZhtlcSyncParams? syncParams; - if (syncType == 'earliest') { - syncParams = ZhtlcSyncParams.earliest(); - } else if (syncType == 'height') { - final v = int.tryParse(syncValueController.text.trim()); - if (v == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Enter a valid block height'), - ), - ); - return; - } - syncParams = ZhtlcSyncParams.height(v); - } else if (syncType == 'date') { - if (selectedDateTime == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please select a date and time'), - ), - ); - return; - } - // Convert to Unix timestamp (seconds since epoch) - final unixTimestamp = - selectedDateTime!.millisecondsSinceEpoch ~/ 1000; - syncParams = ZhtlcSyncParams.date(unixTimestamp); - } - - result = ZhtlcUserConfig( - zcashParamsPath: path, - scanBlocksPerIteration: - int.tryParse(blocksPerIterController.text) ?? 1000, - scanIntervalMs: - int.tryParse(intervalMsController.text) ?? 0, - syncParams: syncParams, - ); - Navigator.of(context).pop(); - }, - child: const Text('Save'), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + ), ), - ], - ); - }, + child: Text( + 'Dates further back take longer to sync and activate the coin', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + ), + ], + ], + ], + ); + } + + void _handleSave() { + final path = zcashPathController.text.trim(); + // On web, allow empty path, otherwise require it + if (!kIsWeb && path.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.zhtlcZcashParamsRequired.tr())), ); - }, - ); + return; + } + + // Create sync params based on type + ZhtlcSyncParams? syncParams; + if (syncType == 'earliest') { + syncParams = ZhtlcSyncParams.earliest(); + } else if (syncType == 'height') { + final v = int.tryParse(syncValueController.text.trim()); + if (v == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.zhtlcInvalidBlockHeight.tr())), + ); + return; + } + syncParams = ZhtlcSyncParams.height(v); + } else if (syncType == 'date') { + if (selectedDateTime == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.zhtlcSelectDateTimeRequired.tr())), + ); + return; + } + // Convert to Unix timestamp (seconds since epoch) + final unixTimestamp = selectedDateTime!.millisecondsSinceEpoch ~/ 1000; + syncParams = ZhtlcSyncParams.date(unixTimestamp); + } + + final result = ZhtlcUserConfig( + zcashParamsPath: path, + scanBlocksPerIteration: + int.tryParse(blocksPerIterController.text) ?? 1000, + scanIntervalMs: int.tryParse(intervalMsController.text) ?? 0, + syncParams: syncParams, + ); + Navigator.of(context).pop(result); + } - return result; + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + LocaleKeys.zhtlcConfigureTitle.tr(args: [widget.asset.id.id]), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!kIsWeb) ...[ + TextField( + controller: zcashPathController, + readOnly: widget.prefilledZcashPath != null, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcZcashParamsPathLabel.tr(), + helperText: widget.prefilledZcashPath != null + ? LocaleKeys.zhtlcPathAutomaticallyDetected.tr() + : LocaleKeys.zhtlcSaplingParamsFolder.tr(), + ), + ), + const SizedBox(height: 12), + ], + TextField( + controller: blocksPerIterController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcBlocksPerIterationLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextField( + controller: intervalMsController, + decoration: InputDecoration( + labelText: LocaleKeys.zhtlcScanIntervalLabel.tr(), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + _buildSyncForm(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.cancel.tr()), + ), + FilledButton(onPressed: _handleSave, child: Text(LocaleKeys.ok.tr())), + ], + ); + } } /// Shows a download progress dialog for Zcash parameters @@ -269,109 +377,127 @@ Future _showZcashDownloadDialog( BuildContext context, ZcashParamsDownloader downloader, ) async { - const downloadTimeout = Duration(minutes: 10); - - // Start the download - final downloadFuture = downloader.downloadParams().timeout( - downloadTimeout, - onTimeout: () => throw TimeoutException( - 'Download timed out after ${downloadTimeout.inMinutes} minutes', - downloadTimeout, - ), - ); - - var downloadComplete = false; - var downloadSuccess = false; - var dialogClosed = false; - - // Show the progress dialog that monitors download completion return showDialog( context: context, barrierDismissible: false, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - // Listen for download completion and close dialog automatically - downloadFuture - .then((result) { - if (!downloadComplete && !dialogClosed && context.mounted) { - downloadComplete = true; - downloadSuccess = result is DownloadResultSuccess; - - // Close the dialog with the result - dialogClosed = true; - Navigator.of(context).pop(downloadSuccess); - } - }) - .catchError((Object e, StackTrace? stackTrace) { - if (!downloadComplete && !dialogClosed && context.mounted) { - downloadComplete = true; - downloadSuccess = false; - - debugPrint('Zcash parameters download failed: $e'); - if (stackTrace != null) { - debugPrint('Stack trace: $stackTrace'); - } - - // Indicate download failed (null result) - dialogClosed = true; - Navigator.of(context).pop(); + builder: (context) => ZcashDownloadProgressDialog(downloader: downloader), + ); +} + +/// Stateful widget for Zcash download progress dialog +class ZcashDownloadProgressDialog extends StatefulWidget { + const ZcashDownloadProgressDialog({required this.downloader, super.key}); + + final ZcashParamsDownloader downloader; + + @override + State createState() => + _ZcashDownloadProgressDialogState(); +} + +class _ZcashDownloadProgressDialogState + extends State { + static const downloadTimeout = Duration(minutes: 10); + bool downloadComplete = false; + bool downloadSuccess = false; + bool dialogClosed = false; + late Future downloadFuture; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + void _startDownload() { + downloadFuture = widget.downloader + .downloadParams() + .timeout( + downloadTimeout, + onTimeout: () => throw TimeoutException( + 'Download timed out after ${downloadTimeout.inMinutes} minutes', + downloadTimeout, + ), + ) + .then((result) { + if (!downloadComplete && !dialogClosed && mounted) { + downloadComplete = true; + downloadSuccess = result is DownloadResultSuccess; + + // Close the dialog with the result + dialogClosed = true; + Navigator.of(context).pop(downloadSuccess); + } + }) + .catchError((Object e, StackTrace? stackTrace) { + if (!downloadComplete && !dialogClosed && mounted) { + downloadComplete = true; + downloadSuccess = false; + + debugPrint('Zcash parameters download failed: $e'); + if (stackTrace != null) { + debugPrint('Stack trace: $stackTrace'); + } + + // Indicate download failed (null result) + dialogClosed = true; + Navigator.of(context).pop(); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(LocaleKeys.zhtlcDownloadingZcashParams.tr()), + content: SizedBox( + height: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + StreamBuilder( + stream: widget.downloader.downloadProgress, + builder: (context, snapshot) { + if (snapshot.hasData) { + final progress = snapshot.data; + return Column( + children: [ + Text( + progress?.displayText ?? '', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: (progress?.percentage ?? 0) / 100, + ), + Text( + '${(progress?.percentage ?? 0).toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ); } - }); - - return AlertDialog( - title: const Text('Downloading Zcash Parameters'), - content: SizedBox( - height: 120, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - StreamBuilder( - stream: downloader.downloadProgress, - builder: (context, snapshot) { - if (snapshot.hasData) { - final progress = snapshot.data; - return Column( - children: [ - Text( - progress?.displayText ?? '', - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - LinearProgressIndicator( - value: (progress?.percentage ?? 0) / 100, - ), - Text( - '${(progress?.percentage ?? 0).toStringAsFixed(1)}%', - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, - ), - ], - ); - } - return const Text('Preparing download...'); - }, - ), - ], - ), + return Text(LocaleKeys.zhtlcPreparingDownload.tr()); + }, ), - actions: [ - TextButton( - onPressed: () async { - if (!dialogClosed) { - dialogClosed = true; - await downloader.cancelDownload(); - Navigator.of(context).pop(false); // Cancelled - } - }, - child: const Text('Cancel'), - ), - ], - ); - }, - ); - }, - ); + ], + ), + ), + actions: [ + TextButton( + onPressed: () async { + if (!dialogClosed) { + dialogClosed = true; + await widget.downloader.cancelDownload(); + Navigator.of(context).pop(false); // Cancelled + } + }, + child: Text(LocaleKeys.cancel.tr()), + ), + ], + ); + } } From d33742e8e3d991a43257ae6e1929ffe90f18dce8 Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 26 Sep 2025 20:57:39 +0200 Subject: [PATCH 05/24] refactor: use repositoryprovider and adjust status bar width --- assets/translations/en.json | 1 + lib/bloc/coins_bloc/coins_repo.dart | 34 +++++---- lib/generated/codegen_loader.g.dart | 1 + lib/main.dart | 8 ++- .../arrr_activation_service.dart | 43 ++++++++---- .../initializer/app_bootstrapper.dart | 3 - .../zhtlc/zhtlc_activation_status_bar.dart | 70 ++++++++++--------- .../zhtlc/zhtlc_configuration_dialog.dart | 2 +- .../zhtlc/zhtlc_configuration_handler.dart | 10 +-- .../wallet_page/wallet_main/wallet_main.dart | 5 +- pubspec.lock | 4 +- sdk | 2 +- 12 files changed, 107 insertions(+), 76 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 001a5af02c..42411ab844 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -778,6 +778,7 @@ "zhtlcDownloadingZcashParams": "Downloading Zcash Parameters", "zhtlcPreparingDownload": "Preparing download...", "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", + "zhtlcDateSyncHint": "Dates further back take longer to sync and activate the coin", "activatingZhtlcCoins": { "one": "Activating ZHTLC coin: {}", "other": "Activating ZHTLC coins: {}" diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 272a3edc05..59667fedc2 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -4,7 +4,7 @@ import 'dart:math' show min; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart' show NetworkImage; -import 'package:get_it/get_it.dart'; + import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -31,9 +31,13 @@ import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; class CoinsRepo { - CoinsRepo({required KomodoDefiSdk kdfSdk, required MM2 mm2}) - : _kdfSdk = kdfSdk, - _mm2 = mm2 { + CoinsRepo({ + required KomodoDefiSdk kdfSdk, + required MM2 mm2, + required ArrrActivationService arrrActivationService, + }) : _kdfSdk = kdfSdk, + _mm2 = mm2, + _arrrActivationService = arrrActivationService { enabledAssetsChanges = StreamController.broadcast( onListen: () => _enabledAssetListenerCount += 1, onCancel: () => _enabledAssetListenerCount -= 1, @@ -42,6 +46,7 @@ class CoinsRepo { final KomodoDefiSdk _kdfSdk; final MM2 _mm2; + final ArrrActivationService _arrrActivationService; final _log = Logger('CoinsRepo'); @@ -741,8 +746,6 @@ class CoinsRepo { bool addToWalletMetadata = true, }) async { try { - final arrrActivationService = GetIt.I(); - _log.info('Starting ZHTLC activation for ${asset.id.id}'); // Use the service's future-based activation which will handle configuration @@ -751,20 +754,21 @@ class CoinsRepo { // This ensures CoinsRepo waits for user inputs for config params from the dialog // before proceeding with activation, and doesn't broadcast activation status // until config parameters are received and (desktop) params files downloaded. - final result = await arrrActivationService.activateArrr(asset); + final result = await _arrrActivationService.activateArrr(asset); + + // Add assets after activation regardless of success or failure + if (addToWalletMetadata) { + await _addAssetsToWalletMetdata([asset.id]); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } result.when( success: (progress) async { _log.info('ZHTLC asset activated successfully: ${asset.id.id}'); - if (addToWalletMetadata) { - await _addAssetsToWalletMetdata([asset.id]); - } - - if (notifyListeners) { - _broadcastAsset(coin.copyWith(state: CoinState.activating)); - } - if (notifyListeners) { _broadcastAsset(coin.copyWith(state: CoinState.active)); if (coin.id.parentId != null) { diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 7d0d4cf2f3..983e801933 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -768,6 +768,7 @@ abstract class LocaleKeys { static const zhtlcDownloadingZcashParams = 'zhtlcDownloadingZcashParams'; static const zhtlcPreparingDownload = 'zhtlcPreparingDownload'; static const zhtlcErrorSettingUpZcash = 'zhtlcErrorSettingUpZcash'; + static const zhtlcDateSyncHint = 'zhtlcDateSyncHint'; static const activatingZhtlcCoins = 'activatingZhtlcCoins'; } diff --git a/lib/main.dart b/lib/main.dart index 23a1127413..cc2516d828 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -69,7 +69,12 @@ Future main() async { sparklineRepository, ); - final coinsRepo = CoinsRepo(kdfSdk: komodoDefiSdk, mm2: mm2); + final arrrActivationService = ArrrActivationService(komodoDefiSdk); + final coinsRepo = CoinsRepo( + kdfSdk: komodoDefiSdk, + mm2: mm2, + arrrActivationService: arrrActivationService, + ); final walletsRepository = WalletsRepository( komodoDefiSdk, mm2Api, @@ -87,6 +92,7 @@ Future main() async { providers: [ RepositoryProvider.value(value: komodoDefiSdk), RepositoryProvider.value(value: mm2Api), + RepositoryProvider.value(value: arrrActivationService), RepositoryProvider.value(value: coinsRepo), RepositoryProvider.value(value: walletsRepository), RepositoryProvider.value(value: sparklineRepository), diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index b6e1b07873..3d9c7c037d 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -112,22 +112,32 @@ class ArrrActivationService { try { _cacheActivationStart(asset.id); - await for (final progress in _sdk.assets.activateAsset(asset)) { - _cacheActivationProgress(asset.id, progress); + ActivationProgress? lastActivationProgress; + await for (final activationProgress in _sdk.assets.activateAsset(asset)) { + _cacheActivationProgress(asset.id, activationProgress); + lastActivationProgress = activationProgress; } _cacheActivationComplete(asset.id); - return ArrrActivationResultSuccess( - Stream.value( - ActivationProgress( - status: 'Activation completed successfully', - progressDetails: ActivationProgressDetails( - currentStep: ActivationStep.complete, - stepCount: 1, + + // return result type by status of activation + if (lastActivationProgress?.isSuccess ?? false) { + return ArrrActivationResultSuccess( + Stream.value( + ActivationProgress( + status: 'Activation completed successfully', + progressDetails: ActivationProgressDetails( + currentStep: ActivationStep.complete, + stepCount: 1, + ), ), ), - ), - ); + ); + } else { + return ArrrActivationResultError( + lastActivationProgress?.errorMessage ?? 'Unknown activation error', + ); + } } catch (e) { _cacheActivationError(asset.id, e.toString()); return ArrrActivationResultError(e.toString()); @@ -211,8 +221,15 @@ class ArrrActivationService { await _configService.saveZhtlcConfig(assetId, config); _log.info('Configuration saved to SDK for ${assetId.id}'); } catch (e) { - _log.severe('Failed to save configuration to SDK for ${assetId.id}: $e'); - completer?.completeError('Failed to save configuration: $e'); + final error = ArrrActivationResultError( + 'Failed to save configuration: $e', + ); + _log.severe( + 'Failed to save configuration to SDK for ${assetId.id}', + error, + ); + completer?.completeError(error); + return; } if (completer != null && !completer.isCompleted) { diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart index 4b6b38605b..b1a5e13453 100644 --- a/lib/services/initializer/app_bootstrapper.dart +++ b/lib/services/initializer/app_bootstrapper.dart @@ -40,9 +40,6 @@ final class AppBootstrapper { // Register core services GetIt.I.registerSingleton(kdfSdk); GetIt.I.registerSingleton(mm2Api); - - final arrrActivationService = ArrrActivationService( kdfSdk); - GetIt.I.registerSingleton(arrrActivationService); } /// A list of futures that should be completed before the app starts diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index 614f17a71e..14d573a443 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -77,47 +77,49 @@ class _ZhtlcActivationStatusBarState extends State { final coinNames = activeStatuses.map((entry) => entry.key.id).join(', '); final coinCount = activeStatuses.length; - return Container( - margin: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8.0), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), - ), - ), + return Padding( + padding: const EdgeInsets.only(bottom: 10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.0), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), ), ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - LocaleKeys.activatingZhtlcCoins.plural( - coinCount, - args: [coinNames], - ), - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.activatingZhtlcCoins.plural( + coinCount, + args: [coinNames], + ), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).textTheme.titleSmall?.color, + fontWeight: FontWeight.w600, + ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart index 9d495c3476..b33668845d 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -259,7 +259,7 @@ class _ZhtlcConfigurationDialogState extends State { ), ), child: Text( - 'Dates further back take longer to sync and activate the coin', + LocaleKeys.zhtlcDateSyncHint.tr(), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.primary, ), diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart index 5c56b7c108..a32f13cdda 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart' @@ -21,13 +21,15 @@ class ZhtlcConfigurationHandler extends StatefulWidget { class _ZhtlcConfigurationHandlerState extends State { late StreamSubscription _configRequestSubscription; - final ArrrActivationService _arrrActivationService = - GetIt.I(); + late final ArrrActivationService _arrrActivationService; final Logger _log = Logger('ZhtlcConfigurationHandler'); @override void initState() { super.initState(); + _arrrActivationService = RepositoryProvider.of( + context, + ); _listenToConfigurationRequests(); } @@ -73,7 +75,7 @@ class _ZhtlcConfigurationHandlerState extends State { } // Track which configuration requests are already being handled to prevent duplicates - final Set _handlingConfigurations = {}; + static final Set _handlingConfigurations = {}; @override Widget build(BuildContext context) { diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index 3c63be33a5..a9b4b65c96 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -5,7 +5,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; @@ -431,7 +430,9 @@ class CoinListView extends StatelessWidget { searchPhrase: searchPhrase, withBalance: withBalance, onCoinItemTap: onActiveCoinItemTap, - arrrActivationService: GetIt.I(), + arrrActivationService: RepositoryProvider.of( + context, + ), ); case AuthorizeMode.hiddenLogin: case AuthorizeMode.noLogin: diff --git a/pubspec.lock b/pubspec.lock index ca2aeb0638..742c59b388 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -593,10 +593,10 @@ packages: dependency: transitive description: name: hive_ce_flutter - sha256: a0989670652eab097b47544f1e5a4456e861b1b01b050098ea0b80a5fabe9909 + sha256: f5bd57fda84402bca7557fedb8c629c96c8ea10fab4a542968d7b60864ca02cc url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hive_flutter: dependency: "direct main" description: diff --git a/sdk b/sdk index 0d6b12ca53..15c43cab3a 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 0d6b12ca533014fb658a07a7f8dbe7db07d6d733 +Subproject commit 15c43cab3aeaace3d70f6a8159e754cd34d86948 From 521ba35188b2e0e131c7107f416c9235d181636e Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 27 Sep 2025 01:49:52 +0200 Subject: [PATCH 06/24] perf(coins-manager): add input debouncer and cache heavy operations - cache heavy operations that aren't expected to change regularly. - add debounce on search input --- assets/translations/en.json | 6 +- lib/bloc/app_bloc_root.dart | 1 - .../coins_manager/coins_manager_bloc.dart | 57 +++++++++++++++---- .../coins_manager/coins_manager_controls.dart | 44 ++++++++++---- .../zhtlc/zhtlc_activation_status_bar.dart | 6 +- macos/Podfile.lock | 2 +- sdk | 2 +- 7 files changed, 86 insertions(+), 32 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index b2217dffa8..b5379c45b2 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -781,9 +781,9 @@ "zhtlcDownloadingZcashParams": "Downloading Zcash Parameters", "zhtlcPreparingDownload": "Preparing download...", "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", - "zhtlcDateSyncHint": "Dates further back take longer to sync and activate the coin", + "zhtlcDateSyncHint": "Selecting a date further in the past can significantly increase the activation time. \nActivation can take a little while the first time, as we need to download some block cache data.", "activatingZhtlcCoins": { - "one": "Activating ZHTLC coin: {}", - "other": "Activating ZHTLC coins: {}" + "one": "Activating ZHTLC coin: {}. Please do not close the app or tab until complete.", + "other": "Activating ZHTLC coins: {}. Please do not close the app or tab until complete." } } \ No newline at end of file diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index c585e2e7e4..4cee603b84 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -23,7 +23,6 @@ import 'package:web_dex/bloc/bridge_form/bridge_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart'; import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; -import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/price_chart/price_chart_bloc.dart'; diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart index d253b8f667..f872ed71aa 100644 --- a/lib/bloc/coins_manager/coins_manager_bloc.dart +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -56,6 +56,11 @@ class CoinsManagerBloc extends Bloc { final TradingEntitiesBloc _tradingEntitiesBloc; final _log = Logger('CoinsManagerBloc'); + // Cache for expensive operations + Map? _cachedKnownCoinsMap; + List? _cachedWalletCoins; + bool? _cachedTestCoinsEnabled; + Future _onCoinsUpdate( CoinsManagerCoinsUpdate event, Emitter emit, @@ -63,7 +68,12 @@ class CoinsManagerBloc extends Bloc { final List filters = []; final mergedCoinsList = mergeCoinLists( - await _getOriginalCoinList(_coinsRepo, event.action), + await _getOriginalCoinList( + _coinsRepo, + event.action, + cachedKnownCoinsMap: _cachedKnownCoinsMap, + cachedWalletCoins: _cachedWalletCoins, + ), state.coins, ).toList(); @@ -109,19 +119,38 @@ class CoinsManagerBloc extends Bloc { CoinsManagerCoinsListReset event, Emitter emit, ) async { + _cachedWalletCoins = null; + _cachedTestCoinsEnabled = null; + emit( state.copyWith( action: event.action, - coins: [], + coins: _cachedKnownCoinsMap?.values.toList() ?? [], selectedCoins: const [], searchPhrase: '', selectedCoinTypes: const [], isSwitching: false, ), ); + + // Cache expensive operations when opening the list, as these values + // should not change while the list is open. + // Known coins map can be cached for longer, but would need to add an + // auth listener to clear it on logout/login, so leaving as-is for now. + // Wallet and test coins can be changed by the user outside of this + // bloc within the same auth session, so they must always be cleared. + _cachedKnownCoinsMap = _coinsRepo.getKnownCoinsMap( + excludeExcludedAssets: true, + ); + _cachedWalletCoins = await _coinsRepo.getWalletCoins(); + _cachedTestCoinsEnabled = + (await _settingsRepository.loadSettings()).testCoinsEnabled; + final List coins = await _getOriginalCoinList( _coinsRepo, event.action, + cachedKnownCoinsMap: _cachedKnownCoinsMap, + cachedWalletCoins: _cachedWalletCoins, ); // Add wallet coins to selected coins if in add mode so that they @@ -288,8 +317,9 @@ class CoinsManagerBloc extends Bloc { } Future> _filterTestCoinsIfNeeded(List coins) async { - final settings = await _settingsRepository.loadSettings(); - return settings.testCoinsEnabled ? coins : removeTestCoins(coins); + _cachedTestCoinsEnabled ??= + (await _settingsRepository.loadSettings()).testCoinsEnabled; + return _cachedTestCoinsEnabled! ? coins : removeTestCoins(coins); } List _filterByPhrase(List coins) { @@ -317,7 +347,8 @@ class CoinsManagerBloc extends Bloc { return selectedCoins; } - final walletCoins = await _coinsRepo.getWalletCoins(); + _cachedWalletCoins ??= await _coinsRepo.getWalletCoins(); + final walletCoins = _cachedWalletCoins!; final result = List.from(selectedCoins); final selectedCoinIds = result.map((c) => c.id.id).toSet(); @@ -491,16 +522,18 @@ class CoinsManagerBloc extends Bloc { Future> _getOriginalCoinList( CoinsRepo coinsRepo, - CoinsManagerAction action, -) async { + CoinsManagerAction action, { + Map? cachedKnownCoinsMap, + List? cachedWalletCoins, +}) async { switch (action) { case CoinsManagerAction.add: - return coinsRepo - .getKnownCoinsMap(excludeExcludedAssets: true) - .values - .toList(); + final knownCoinsMap = + cachedKnownCoinsMap ?? + coinsRepo.getKnownCoinsMap(excludeExcludedAssets: true); + return knownCoinsMap.values.toList(); case CoinsManagerAction.remove: - return coinsRepo.getWalletCoins(); + return cachedWalletCoins ?? await coinsRepo.getWalletCoins(); case CoinsManagerAction.none: return []; } diff --git a/lib/views/wallet/coins_manager/coins_manager_controls.dart b/lib/views/wallet/coins_manager/coins_manager_controls.dart index aa00abcc52..0259da5a36 100644 --- a/lib/views/wallet/coins_manager/coins_manager_controls.dart +++ b/lib/views/wallet/coins_manager/coins_manager_controls.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -10,14 +11,32 @@ import 'package:web_dex/views/custom_token_import/custom_token_import_button.dar import 'package:web_dex/views/wallet/coins_manager/coins_manager_filters_dropdown.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_select_all_button.dart'; -class CoinsManagerFilters extends StatelessWidget { - const CoinsManagerFilters({Key? key, required this.isMobile}) - : super(key: key); +class CoinsManagerFilters extends StatefulWidget { + const CoinsManagerFilters({super.key, required this.isMobile}); final bool isMobile; + @override + State createState() => _CoinsManagerFiltersState(); +} + +class _CoinsManagerFiltersState extends State { + late final Debouncer _debouncer; + + @override + void initState() { + super.initState(); + _debouncer = Debouncer(duration: const Duration(milliseconds: 100)); + } + + @override + void dispose() { + _debouncer.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - if (isMobile) { + if (widget.isMobile) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -70,7 +89,7 @@ class CoinsManagerFilters extends StatelessWidget { Widget _buildSearchField(BuildContext context) { return UiTextFormField( key: const Key('coins-manager-search-field'), - fillColor: isMobile + fillColor: widget.isMobile ? theme.custom.coinsManagerTheme.searchFieldMobileBackgroundColor : null, autocorrect: false, @@ -80,13 +99,14 @@ class CoinsManagerFilters extends StatelessWidget { prefixIcon: const Icon(Icons.search, size: 18), inputFormatters: [LengthLimitingTextInputFormatter(40)], hintText: LocaleKeys.searchAssets.tr(), - hintTextStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - onChanged: (String? text) => context - .read() - .add(CoinsManagerSearchUpdate(text: text ?? '')), + hintTextStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + onChanged: (String? text) => _debouncer.run(() { + if (mounted) { + context.read().add( + CoinsManagerSearchUpdate(text: text ?? ''), + ); + } + }), ); } } diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index 14d573a443..7992fcdaa3 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/generated/codegen_loader.g.dart' show LocaleKeys; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/services/arrr_activation/arrr_config.dart'; @@ -35,6 +36,7 @@ class _ZhtlcActivationStatusBarState extends State { } void _startPeriodicRefresh() { + _refreshStatuses(); _refreshTimer = Timer.periodic(const Duration(seconds: 1), (_) { _refreshStatuses(); }); @@ -107,8 +109,8 @@ class _ZhtlcActivationStatusBarState extends State { ), const SizedBox(width: 12), Expanded( - child: Text( - LocaleKeys.activatingZhtlcCoins.plural( + child: AutoScrollText( + text: LocaleKeys.activatingZhtlcCoins.plural( coinCount, args: [coinNames], ), diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 200fa59fe5..0345a714f6 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -218,7 +218,7 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - komodo_defi_framework: e4ae27a407c2d1f1f8b11217b0077e6b7511cd72 + komodo_defi_framework: 725599127b357521f4567b16192bf07d7ad1d4b0 local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 diff --git a/sdk b/sdk index 15c43cab3a..8626160163 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 15c43cab3aeaace3d70f6a8159e754cd34d86948 +Subproject commit 86261601634703681828bfc421f11c7fee68c329 From 9c689374a75b887a3188be727d8d6b12f424537e Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 27 Sep 2025 02:56:19 +0200 Subject: [PATCH 07/24] fix(activation): swap page activation, improve list and disposal management --- lib/bloc/coins_bloc/coins_repo.dart | 23 +++++++++++--- lib/bloc/taker_form/taker_bloc.dart | 24 ++++++++++++--- lib/blocs/maker_form_bloc.dart | 10 +++++++ .../arrr_activation_service.dart | 30 +++++++++++++++---- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 59667fedc2..73d4426979 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -287,15 +287,30 @@ class CoinsRepo { return; } - if (assets.any((asset) => asset.id.subClass == CoinSubClass.zhtlc)) { - return _activateZhtlcAssets( - assets, - assets.map((asset) => _assetToCoinWithoutAddress(asset)).toList(), + // Separate ZHTLC and regular assets + final zhtlcAssets = assets + .where((asset) => asset.id.subClass == CoinSubClass.zhtlc) + .toList(); + final regularAssets = assets + .where((asset) => asset.id.subClass != CoinSubClass.zhtlc) + .toList(); + + // Process ZHTLC assets separately + if (zhtlcAssets.isNotEmpty) { + await _activateZhtlcAssets( + zhtlcAssets, + zhtlcAssets.map((asset) => _assetToCoinWithoutAddress(asset)).toList(), notifyListeners: notifyListeners, addToWalletMetadata: addToWalletMetadata, ); } + // Continue with regular asset processing for non-ZHTLC assets + if (regularAssets.isEmpty) return; + + // Update assets list to only include regular assets for remaining processing + assets = regularAssets; + if (addToWalletMetadata) { // Ensure the wallet metadata is updated with the assets before activation // This is to ensure that the wallet metadata is always in sync with the assets diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index 01e7ddc976..04c5b47435 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -34,6 +34,7 @@ class TakerBloc extends Bloc { required KomodoDefiSdk kdfSdk, }) : _dexRepo = dexRepository, _coinsRepo = coinsRepository, + _sdk = kdfSdk, super(TakerState.initial()) { _validator = TakerValidator( bloc: this, @@ -81,6 +82,7 @@ class TakerBloc extends Bloc { final DexRepository _dexRepo; final CoinsRepo _coinsRepo; + final KomodoDefiSdk _sdk; Timer? _maxSellAmountTimer; bool _activatingAssets = false; bool _waitingForWallet = true; @@ -408,6 +410,21 @@ class TakerBloc extends Bloc { availableBalanceState: () => AvailableBalanceState.loading)); } + // Required here because of the manual RPC calls that bypass the sdk + final activeAssets = await _sdk.assets.getActivatedAssets(); + final isAssetActive = + activeAssets.any((asset) => asset.id == state.sellCoin!.id); + if (!isAssetActive) { + // Intentionally leave the state as loading so that a spinner is shown + // instead of a "0.00" balance hinting that the asset is active when it + // is not. + if (state.availableBalanceState != AvailableBalanceState.loading) { + emitter(state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading)); + } + return; + } + if (!_isLoggedIn) { emitter(state.copyWith( availableBalanceState: () => AvailableBalanceState.unavailable)); @@ -438,11 +455,10 @@ class TakerBloc extends Bloc { try { return await retry( () => _dexRepo.getMaxTakerVolume(abbr), - maxAttempts: 5, + maxAttempts: 3, backoffStrategy: LinearBackoff( - initialDelay: const Duration(seconds: 2), - increment: const Duration(seconds: 2), - maxDelay: const Duration(seconds: 10), + initialDelay: const Duration(milliseconds: 500), + maxDelay: const Duration(seconds: 2), ), ); } catch (_) { diff --git a/lib/blocs/maker_form_bloc.dart b/lib/blocs/maker_form_bloc.dart index c40a435129..e24bd86807 100644 --- a/lib/blocs/maker_form_bloc.dart +++ b/lib/blocs/maker_form_bloc.dart @@ -275,6 +275,16 @@ class MakerFormBloc implements BlocBase { return; } + final activeAssets = await kdfSdk.assets.getActivatedAssets(); + final isAssetActive = activeAssets.any((asset) => asset.id == coin.id); + if (!isAssetActive) { + // Intentionally leave in the loading state to avoid showing a "0.00" balance + // while the asset is activating. + maxSellAmount = null; + availableBalanceState = AvailableBalanceState.loading; + return; + } + Rational? amount = await dexRepository.getMaxMakerVolume(coin.abbr); if (amount != null) { maxSellAmount = amount; diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index 3d9c7c037d..846ab92ecd 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -28,6 +28,9 @@ class ArrrActivationService { /// Subscription to auth state changes StreamSubscription? _authSubscription; + /// Flag to track if the service is being disposed + bool _isDisposing = false; + /// Stream of configuration requests that UI can listen to Stream get configurationRequests => _configRequestController.stream; @@ -38,6 +41,9 @@ class ArrrActivationService { Asset asset, { ZhtlcUserConfig? initialConfig, }) async { + if (_isDisposing || _configRequestController.isClosed) { + throw StateError('ArrrActivationService has been disposed'); + } var config = initialConfig ?? await _getOrRequestConfiguration(asset.id); if (config == null) { @@ -53,11 +59,12 @@ class ArrrActivationService { _log.info('Requesting configuration for ${asset.id.id}'); - // Check if stream controller is closed - if (_configRequestController.isClosed) { + // Check if stream controller is closed or service is disposing + if (_isDisposing || _configRequestController.isClosed) { _log.severe( - 'Configuration request controller is closed for ${asset.id.id}', + 'Configuration request controller is closed or service is disposing for ${asset.id.id}', ); + _configCompleters.remove(asset.id); return ArrrActivationResultError( 'Configuration system is not available', ); @@ -213,6 +220,10 @@ class ArrrActivationService { AssetId assetId, ZhtlcUserConfig config, ) async { + if (_isDisposing) { + _log.warning('Ignoring configuration submission - service is disposing'); + return; + } _log.info('Submitting configuration for ${assetId.id}'); // Save configuration to SDK @@ -351,17 +362,24 @@ class ArrrActivationService { /// Dispose resources void dispose() { + // Mark as disposing to prevent new operations + _isDisposing = true; + // Cancel auth subscription first _authSubscription?.cancel(); - // Complete any pending configuration requests + // Complete any pending configuration requests with a specific error for (final completer in _configCompleters.values) { if (!completer.isCompleted) { - completer.complete(null); + completer.completeError(StateError('Service is being disposed')); } } _configCompleters.clear(); - _configRequestController.close(); + + // Close controller after ensuring all operations are complete + if (!_configRequestController.isClosed) { + _configRequestController.close(); + } } } From 16c5e5a16203835baf65765d439a4df00af4d927 Mon Sep 17 00:00:00 2001 From: Francois Date: Sun, 28 Sep 2025 00:53:43 +0200 Subject: [PATCH 08/24] fix(zhtlc): update activation warning, add padding, and stealth text --- assets/translations/en.json | 2 +- lib/bloc/coins_bloc/coins_bloc.dart | 5 ++- .../zhtlc/zhtlc_activation_status_bar.dart | 29 +++++++++++++++ .../zhtlc/zhtlc_configuration_dialog.dart | 24 ++++++++++++ .../zhtlc/zhtlc_configuration_handler.dart | 37 ++++++++++++++++--- 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index b5379c45b2..b6f1d5d3b0 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -781,7 +781,7 @@ "zhtlcDownloadingZcashParams": "Downloading Zcash Parameters", "zhtlcPreparingDownload": "Preparing download...", "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", - "zhtlcDateSyncHint": "Selecting a date further in the past can significantly increase the activation time. \nActivation can take a little while the first time, as we need to download some block cache data.", + "zhtlcDateSyncHint": "Selecting a date further in the past can significantly increase the activation time. \nActivation can take a little while the first time to download block cache data.\n\nTransactions and balance prior to the sync date may be missing.\nOften this can be restored by sending in and out new transactions", "activatingZhtlcCoins": { "one": "Activating ZHTLC coin: {}. Please do not close the app or tab until complete.", "other": "Activating ZHTLC coins: {}. Please do not close the app or tab until complete." diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 1736be88df..3277333bf5 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -383,7 +383,10 @@ class CoinsBloc extends Bloc { return sdkCoin?.copyWith(state: CoinState.activating); }) .where((coin) => coin != null) - .cast(), + .cast() + // Do not pre-populate zhtlc coins, as they require configuration + // and longer activation times, and are handled separately. + .where((coin) => coin.id.subClass != CoinSubClass.zhtlc), key: (element) => (element as Coin).id.id, ); return state.copyWith( diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index 7992fcdaa3..7f079ce727 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart' show LocaleKeys; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/services/arrr_activation/arrr_config.dart'; @@ -22,19 +24,30 @@ class ZhtlcActivationStatusBar extends StatefulWidget { class _ZhtlcActivationStatusBarState extends State { Timer? _refreshTimer; Map _cachedStatuses = {}; + StreamSubscription? _authSubscription; @override void initState() { super.initState(); _startPeriodicRefresh(); + _subscribeToAuthChanges(); } @override void dispose() { _refreshTimer?.cancel(); + _authSubscription?.cancel(); super.dispose(); } + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + _handleSignedOut(); + } + }); + } + void _startPeriodicRefresh() { _refreshStatuses(); _refreshTimer = Timer.periodic(const Duration(seconds: 1), (_) { @@ -52,6 +65,22 @@ class _ZhtlcActivationStatusBarState extends State { } } + void _handleSignedOut() { + if (!mounted) { + _cachedStatuses = {}; + return; + } + + final assetIds = _cachedStatuses.keys.toList(); + for (final assetId in assetIds) { + widget.activationService.clearActivationStatus(assetId); + } + + setState(() { + _cachedStatuses = {}; + }); + } + @override Widget build(BuildContext context) { // Filter out completed or error statuses older than 5 seconds diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart index b33668845d..2dcb740e5e 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' show ZhtlcSyncParams; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' @@ -15,6 +16,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart' import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; /// Shows ZHTLC configuration dialog similar to handleZhtlcConfigDialog from SDK example /// This is bad practice (UI logic in utils), but necessary for now because of @@ -94,6 +96,8 @@ class _ZhtlcConfigurationDialogState extends State { late final TextEditingController blocksPerIterController; late final TextEditingController intervalMsController; late final TextEditingController syncValueController; + StreamSubscription? _authSubscription; + bool _dismissedDueToAuthChange = false; String syncType = 'date'; DateTime? selectedDateTime; @@ -114,10 +118,13 @@ class _ZhtlcConfigurationDialogState extends State { // Initialize with default date (2 days ago) selectedDateTime = DateTime.now().subtract(const Duration(days: 2)); syncValueController.text = formatDate(selectedDateTime!); + + _subscribeToAuthChanges(); } @override void dispose() { + _authSubscription?.cancel(); zcashPathController.dispose(); blocksPerIterController.dispose(); intervalMsController.dispose(); @@ -370,6 +377,23 @@ class _ZhtlcConfigurationDialogState extends State { ], ); } + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + _handleAuthSignedOut(); + } + }); + } + + void _handleAuthSignedOut() { + if (_dismissedDueToAuthChange || !mounted) { + return; + } + + _dismissedDueToAuthChange = true; + Navigator.of(context).maybePop(null); + } } /// Shows a download progress dialog for Zcash parameters diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart index a32f13cdda..ff581f2d6c 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_handler.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logging/logging.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; import 'package:web_dex/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart' show confirmZhtlcConfiguration; @@ -22,6 +24,7 @@ class ZhtlcConfigurationHandler extends StatefulWidget { class _ZhtlcConfigurationHandlerState extends State { late StreamSubscription _configRequestSubscription; late final ArrrActivationService _arrrActivationService; + StreamSubscription? _authSubscription; final Logger _log = Logger('ZhtlcConfigurationHandler'); @override @@ -31,11 +34,13 @@ class _ZhtlcConfigurationHandlerState extends State { context, ); _listenToConfigurationRequests(); + _subscribeToAuthChanges(); } @override void dispose() { _configRequestSubscription.cancel(); + _authSubscription?.cancel(); super.dispose(); } @@ -49,7 +54,7 @@ class _ZhtlcConfigurationHandlerState extends State { 'Received config request for ${configRequest.asset.id.id}', ); if (mounted && - !_handlingConfigurations.contains(configRequest.asset.id.id)) { + !_handlingConfigurations.contains(configRequest.asset.id)) { _log.info( 'Showing configuration dialog for ${configRequest.asset.id.id}', ); @@ -57,7 +62,7 @@ class _ZhtlcConfigurationHandlerState extends State { } else { _log.warning( 'Skipping config request for ${configRequest.asset.id.id} ' - '(mounted: $mounted, already handling: ${_handlingConfigurations.contains(configRequest.asset.id.id)})', + '(mounted: $mounted, already handling: ${_handlingConfigurations.contains(configRequest.asset.id)})', ); } }, @@ -75,7 +80,7 @@ class _ZhtlcConfigurationHandlerState extends State { } // Track which configuration requests are already being handled to prevent duplicates - static final Set _handlingConfigurations = {}; + static final Set _handlingConfigurations = {}; @override Widget build(BuildContext context) { @@ -86,7 +91,7 @@ class _ZhtlcConfigurationHandlerState extends State { BuildContext context, ZhtlcConfigurationRequest configRequest, ) async { - _handlingConfigurations.add(configRequest.asset.id.id); + _handlingConfigurations.add(configRequest.asset.id); _log.info('Starting configuration dialog for ${configRequest.asset.id.id}'); try { @@ -125,7 +130,7 @@ class _ZhtlcConfigurationHandlerState extends State { ); _arrrActivationService.cancelConfiguration(configRequest.asset.id); } finally { - _handlingConfigurations.remove(configRequest.asset.id.id); + _handlingConfigurations.remove(configRequest.asset.id); _log.info( 'Finished handling configuration for ${configRequest.asset.id.id}', ); @@ -135,4 +140,26 @@ class _ZhtlcConfigurationHandlerState extends State { /// Check if the configuration request listener is active bool get isListeningToConfigurationRequests => !_configRequestSubscription.isPaused; + + void _subscribeToAuthChanges() { + _authSubscription = context.read().stream.listen((state) { + if (state.currentUser == null) { + _handleSignedOut(); + } + }); + } + + void _handleSignedOut() { + if (_handlingConfigurations.isEmpty) { + return; + } + + _log.info('Auth signed out - clearing pending ZHTLC configuration state'); + final pendingAssetIds = List.of(_handlingConfigurations); + _handlingConfigurations.clear(); + + for (final assetId in pendingAssetIds) { + _arrrActivationService.cancelConfiguration(assetId); + } + } } From b351e11a1931df6a48b1c471210cc712e8af82e1 Mon Sep 17 00:00:00 2001 From: Francois Date: Sun, 28 Sep 2025 19:26:26 +0200 Subject: [PATCH 09/24] feat(orderbook): migrate to v2 orderbook RPC via SDK --- .gitmodules | 2 +- lib/bloc/app_bloc_root.dart | 2 +- lib/blocs/orderbook_bloc.dart | 74 +++++++++++----- lib/mm2/mm2_api/mm2_api.dart | 71 ++++------------ .../rpc/orderbook/orderbook_request.dart | 23 ----- .../rpc/orderbook/orderbook_response.dart | 15 ---- lib/model/orderbook/order.dart | 72 +++++++++++++++- lib/model/orderbook/orderbook.dart | 84 +++++++++++++++---- lib/model/orderbook_model.dart | 19 +++-- .../orderbook/orderbook_error_message.dart | 46 +++++----- lib/views/dex/orderbook/orderbook_view.dart | 30 ++++--- sdk | 2 +- 12 files changed, 258 insertions(+), 182 deletions(-) delete mode 100644 lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart delete mode 100644 lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart diff --git a/.gitmodules b/.gitmodules index cb4eb4a2a4..f91ac653b3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "sdk"] path = sdk url = https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - branch = bugfix/zhltc-activation-fixes + branch = bugfix/orderbook-rpc-schema update = checkout fetchRecurseSubmodules = on-demand ignore = dirty diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index 4cee603b84..a889861210 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -171,7 +171,7 @@ class AppBlocRoot extends StatelessWidget { dexRepository: dexRepository, ), ), - RepositoryProvider(create: (_) => OrderbookBloc(api: mm2Api)), + RepositoryProvider(create: (_) => OrderbookBloc(sdk: komodoDefiSdk)), RepositoryProvider(create: (_) => myOrdersService), RepositoryProvider( create: (_) => KmdRewardsBloc(coinsRepository, mm2Api), diff --git a/lib/blocs/orderbook_bloc.dart b/lib/blocs/orderbook_bloc.dart index c5427de616..4972c22032 100644 --- a/lib/blocs/orderbook_bloc.dart +++ b/lib/blocs/orderbook_bloc.dart @@ -1,13 +1,15 @@ import 'dart:async'; +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show GeneralErrorResponse, OrderbookResponse; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/blocs/bloc_base.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; +import 'package:web_dex/shared/utils/utils.dart'; class OrderbookBloc implements BlocBase { - OrderbookBloc({required Mm2Api api}) { - _api = api; + OrderbookBloc({required KomodoDefiSdk sdk}) { + _sdk = sdk; _timer = Timer.periodic( const Duration(seconds: 3), @@ -15,7 +17,7 @@ class OrderbookBloc implements BlocBase { ); } - late Mm2Api _api; + late KomodoDefiSdk _sdk; Timer? _timer; // keys are 'base/rel' Strings @@ -27,21 +29,21 @@ class OrderbookBloc implements BlocBase { _subscriptions.forEach((pair, subs) => subs.controller.close()); } - OrderbookResponse? getInitialData(String base, String rel) { + OrderbookResult? getInitialData(String base, String rel) { final String pair = '$base/$rel'; final OrderbookSubscription? subscription = _subscriptions[pair]; return subscription?.initialData; } - Stream getOrderbookStream(String base, String rel) { + Stream getOrderbookStream(String base, String rel) { final String pair = '$base/$rel'; final OrderbookSubscription? subscription = _subscriptions[pair]; if (subscription != null) { return subscription.stream; } else { - final controller = StreamController.broadcast(); + final controller = StreamController.broadcast(); final sink = controller.sink; final stream = controller.stream; @@ -58,7 +60,7 @@ class OrderbookBloc implements BlocBase { } Future _updateOrderbooks() async { - final List pairs = List.from(_subscriptions.keys); + final List pairs = List.of(_subscriptions.keys); for (String pair in pairs) { final OrderbookSubscription? subscription = _subscriptions[pair]; @@ -79,13 +81,34 @@ class OrderbookBloc implements BlocBase { final List coins = pair.split('/'); - final OrderbookResponse response = await _api.getOrderbook(OrderbookRequest( - base: coins[0], - rel: coins[1], - )); - - subscription.initialData = response; - subscription.sink.add(response); + try { + final OrderbookResponse response = await _sdk.client.rpc.orderbook + .orderbook(base: coins[0], rel: coins[1]); + + final result = OrderbookResult(response: response); + subscription.initialData = result; + subscription.sink.add(result); + } on GeneralErrorResponse catch (error) { + final message = error.error ?? error.toString(); + log( + 'Orderbook request failed for pair $pair: $message', + path: 'OrderbookBloc._fetchOrderbook', + isError: true, + ).ignore(); + final result = OrderbookResult(error: message); + subscription.initialData = result; + subscription.sink.add(result); + } catch (e, s) { + log( + 'Unexpected orderbook error for pair $pair: $e', + path: 'OrderbookBloc._fetchOrderbook', + trace: s, + isError: true, + ).ignore(); + final result = OrderbookResult(error: e.toString()); + subscription.initialData = result; + subscription.sink.add(result); + } } } @@ -97,8 +120,17 @@ class OrderbookSubscription { required this.stream, }); - OrderbookResponse? initialData; - final StreamController controller; - final Sink sink; - final Stream stream; + OrderbookResult? initialData; + final StreamController controller; + final Sink sink; + final Stream stream; +} + +class OrderbookResult { + const OrderbookResult({this.response, this.error}); + + final OrderbookResponse? response; + final String? error; + + bool get hasError => error != null; } diff --git a/lib/mm2/mm2_api/mm2_api.dart b/lib/mm2/mm2_api/mm2_api.dart index 8fcaa1ffa2..12f9a03dac 100644 --- a/lib/mm2/mm2_api/mm2_api.dart +++ b/lib/mm2/mm2_api/mm2_api.dart @@ -33,8 +33,6 @@ import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_request.dart import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_v2_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/order_status/order_status_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/order_status/order_status_response.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_req.dart'; import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart'; @@ -52,16 +50,13 @@ import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.d import 'package:web_dex/mm2/mm2_api/rpc/validateaddress/validateaddress_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/version/version_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; -import 'package:web_dex/model/orderbook/orderbook.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/utils.dart'; class Mm2Api { - Mm2Api({ - required MM2 mm2, - required KomodoDefiSdk sdk, - }) : _sdk = sdk, - _mm2 = mm2 { + Mm2Api({required MM2 mm2, required KomodoDefiSdk sdk}) + : _sdk = sdk, + _mm2 = mm2 { nft = Mm2ApiNft(_mm2.call, sdk); } @@ -113,10 +108,7 @@ class Mm2Api { denom: rational.denominator.toString(), ); - return MaxTakerVolResponse( - coin: abbr, - result: result, - ); + return MaxTakerVolResponse(coin: abbr, result: result); } Future?> getActiveSwaps( @@ -478,11 +470,7 @@ class Mm2Api { denom: rational.denominator.toString(), ); - return MaxMakerVolResponse( - coin: coinAbbr, - volume: result, - balance: result, - ); + return MaxMakerVolResponse(coin: coinAbbr, volume: result, balance: result); } Future getMinTradingVol( @@ -505,36 +493,6 @@ class Mm2Api { } } - Future getOrderbook(OrderbookRequest request) async { - try { - final JsonMap json = await _mm2.call(request); - - if (json['error'] != null) { - return OrderbookResponse( - request: request, - error: json['error'] as String?, - ); - } - - return OrderbookResponse( - request: request, - result: Orderbook.fromJson(json), - ); - } catch (e, s) { - log( - 'Error getting orderbook ${request.base}/${request.rel}: $e', - path: 'api => getOrderbook', - trace: s, - isError: true, - ).ignore(); - - return OrderbookResponse( - request: request, - error: e.toString(), - ); - } - } - Future getOrderBookDepth( List> pairs, CoinsRepo coinsRepository, @@ -557,10 +515,13 @@ class Mm2Api { } Future< - ApiResponse>> getTradePreimage( - TradePreimageRequest request, - ) async { + ApiResponse< + TradePreimageRequest, + TradePreimageResponseResult, + Map + > + > + getTradePreimage(TradePreimageRequest request) async { try { final JsonMap responseJson = await _mm2.call(request); if (responseJson['error'] != null) { @@ -577,9 +538,7 @@ class Mm2Api { trace: s, isError: true, ).ignore(); - return ApiResponse( - request: request, - ); + return ApiResponse(request: request); } } @@ -635,9 +594,7 @@ class Mm2Api { await _mm2.call(StopReq()); } - Future showPrivKey( - ShowPrivKeyRequest request, - ) async { + Future showPrivKey(ShowPrivKeyRequest request) async { try { final JsonMap json = await _mm2.call(request); if (json['error'] != null) { diff --git a/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart b/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart deleted file mode 100644 index d742cb063e..0000000000 --- a/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; - -class OrderbookRequest implements BaseRequest { - OrderbookRequest({ - required this.base, - required this.rel, - }); - - final String base; - final String rel; - @override - late String userpass; - @override - final String method = 'orderbook'; - - @override - Map toJson() => { - 'userpass': userpass, - 'method': method, - 'base': base, - 'rel': rel, - }; -} diff --git a/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart b/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart deleted file mode 100644 index 55fa0e5118..0000000000 --- a/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_request.dart'; -import 'package:web_dex/model/orderbook/orderbook.dart'; - -class OrderbookResponse - implements ApiResponse { - OrderbookResponse({required this.request, this.result, this.error}); - - @override - final OrderbookRequest request; - @override - final Orderbook? result; - @override - final String? error; -} diff --git a/lib/model/orderbook/order.dart b/lib/model/orderbook/order.dart index f80b63c0dc..e039c90197 100644 --- a/lib/model/orderbook/order.dart +++ b/lib/model/orderbook/order.dart @@ -1,3 +1,5 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' + show NumericValue, OrderInfo; import 'package:rational/rational.dart'; import 'package:uuid/uuid.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -29,15 +31,56 @@ class Order { uuid: json['uuid'], pubkey: json['pubkey'], price: fract2rat(json['price_fraction']) ?? Rational.parse(json['price']), - maxVolume: fract2rat(json['base_max_volume_fraction']) ?? + maxVolume: + fract2rat(json['base_max_volume_fraction']) ?? Rational.parse(json['base_max_volume']), - minVolume: fract2rat(json['base_min_volume_fraction']) ?? + minVolume: + fract2rat(json['base_min_volume_fraction']) ?? Rational.parse(json['base_min_volume']), - minVolumeRel: fract2rat(json['rel_min_volume_fraction']) ?? + minVolumeRel: + fract2rat(json['rel_min_volume_fraction']) ?? Rational.parse(json['rel_min_volume']), ); } + factory Order.fromOrderInfo( + OrderInfo info, { + required String base, + required String rel, + required OrderDirection direction, + }) { + final Rational? price = _numericValueToRational(info.price); + + final Rational? maxVolume = _numericValueToRational( + info.baseMaxVolume ?? info.baseMaxVolumeAggregated, + ); + + if (price == null || maxVolume == null) { + throw ArgumentError('Invalid price or maxVolume in OrderInfo'); + } + + final Rational? minVolume = _numericValueToRational( + info.baseMinVolume, + ); + + final Rational? minVolumeRel = + _numericValueToRational(info.relMinVolume) ?? + (minVolume != null ? minVolume * price : null); + + return Order( + base: base, + rel: rel, + direction: direction, + price: price, + maxVolume: maxVolume, + address: info.address?.addressData, + uuid: info.uuid, + pubkey: info.pubkey, + minVolume: minVolume, + minVolumeRel: minVolumeRel, + ); + } + final String base; final String rel; final OrderDirection direction; @@ -51,6 +94,29 @@ class Order { bool get isBid => direction == OrderDirection.bid; bool get isAsk => direction == OrderDirection.ask; + + static Rational? _numericValueToRational(NumericValue? value) { + if (value == null) return null; + if (value.rational != null) { + return value.rational; + } + final fraction = value.fraction; + if (fraction != null) { + final fractionRat = fract2rat(fraction.toJson(), false); + if (fractionRat != null) { + return fractionRat; + } + } + final decimal = value.decimal.trim(); + if (decimal.isEmpty) { + return null; + } + try { + return Rational.parse(decimal); + } catch (_) { + return null; + } + } } enum OrderDirection { bid, ask } diff --git a/lib/model/orderbook/orderbook.dart b/lib/model/orderbook/orderbook.dart index 386ba0dcfb..af1c3b57fd 100644 --- a/lib/model/orderbook/orderbook.dart +++ b/lib/model/orderbook/orderbook.dart @@ -1,3 +1,4 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as sdk; import 'package:rational/rational.dart'; import 'package:web_dex/model/orderbook/order.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -20,31 +21,86 @@ class Orderbook { base: json['base'], rel: json['rel'], asks: json['asks'] - .map((dynamic item) => Order.fromJson( - item, - direction: OrderDirection.ask, - otherCoin: json['rel'], - )) + .map( + (dynamic item) => Order.fromJson( + item, + direction: OrderDirection.ask, + otherCoin: json['rel'], + ), + ) .toList(), bids: json['bids'] - .map((dynamic item) => Order.fromJson( - item, - direction: OrderDirection.bid, - otherCoin: json['base'], - )) + .map( + (dynamic item) => Order.fromJson( + item, + direction: OrderDirection.bid, + otherCoin: json['base'], + ), + ) .toList(), - bidsBaseVolTotal: fract2rat(json['total_bids_base_vol_fraction']) ?? + bidsBaseVolTotal: + fract2rat(json['total_bids_base_vol_fraction']) ?? Rational.parse(json['total_bids_base_vol']), - bidsRelVolTotal: fract2rat(json['total_bids_rel_vol_fraction']) ?? + bidsRelVolTotal: + fract2rat(json['total_bids_rel_vol_fraction']) ?? Rational.parse(json['total_bids_rel_vol']), - asksBaseVolTotal: fract2rat(json['total_asks_base_vol_fraction']) ?? + asksBaseVolTotal: + fract2rat(json['total_asks_base_vol_fraction']) ?? Rational.parse(json['total_asks_base_vol']), - asksRelVolTotal: fract2rat(json['total_asks_rel_vol_fraction']) ?? + asksRelVolTotal: + fract2rat(json['total_asks_rel_vol_fraction']) ?? Rational.parse(json['total_asks_rel_vol']), timestamp: json['timestamp'], ); } + factory Orderbook.fromSdkResponse(sdk.OrderbookResponse response) { + List _mapOrders( + List orders, + OrderDirection direction, + ) { + return orders + .map( + (info) => Order.fromOrderInfo( + info, + base: response.base, + rel: response.rel, + direction: direction, + ), + ) + .toList(); + } + + final asks = _mapOrders(response.asks, OrderDirection.ask); + final bids = _mapOrders(response.bids, OrderDirection.bid); + + Rational _totalBaseVolume(List orders) { + return orders.fold( + Rational.zero, + (sum, order) => sum + order.maxVolume, + ); + } + + Rational _totalRelVolume(List orders) { + return orders.fold( + Rational.zero, + (sum, order) => sum + (order.maxVolume * order.price), + ); + } + + return Orderbook( + base: response.base, + rel: response.rel, + bidsBaseVolTotal: _totalBaseVolume(bids), + bidsRelVolTotal: _totalRelVolume(bids), + asksBaseVolTotal: _totalBaseVolume(asks), + asksRelVolTotal: _totalRelVolume(asks), + bids: bids, + asks: asks, + timestamp: response.timestamp, + ); + } + final String base; final String rel; final List bids; diff --git a/lib/model/orderbook_model.dart b/lib/model/orderbook_model.dart index 2d0e0e0470..930c77302a 100644 --- a/lib/model/orderbook_model.dart +++ b/lib/model/orderbook_model.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:web_dex/blocs/orderbook_bloc.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/model/coin.dart'; class OrderbookModel { @@ -33,12 +32,12 @@ class OrderbookModel { StreamSubscription? _orderbookListener; - OrderbookResponse? _response; - final _responseCtrl = StreamController.broadcast(); - Sink get _inResponse => _responseCtrl.sink; - Stream get outResponse => _responseCtrl.stream; - OrderbookResponse? get response => _response; - set response(OrderbookResponse? value) { + OrderbookResult? _response; + final _responseCtrl = StreamController.broadcast(); + Sink get _inResponse => _responseCtrl.sink; + Stream get outResponse => _responseCtrl.stream; + OrderbookResult? get response => _response; + set response(OrderbookResult? value) { _response = value; _inResponse.add(_response); } @@ -59,8 +58,10 @@ class OrderbookModel { response = null; if (base == null || rel == null) return; - final stream = - orderBookRepository.getOrderbookStream(base!.abbr, rel!.abbr); + final stream = orderBookRepository.getOrderbookStream( + base!.abbr, + rel!.abbr, + ); _orderbookListener = stream.listen((resp) => response = resp); } diff --git a/lib/views/dex/orderbook/orderbook_error_message.dart b/lib/views/dex/orderbook/orderbook_error_message.dart index ac13b32973..e7e9912a3d 100644 --- a/lib/views/dex/orderbook/orderbook_error_message.dart +++ b/lib/views/dex/orderbook/orderbook_error_message.dart @@ -1,17 +1,16 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; class OrderbookErrorMessage extends StatefulWidget { const OrderbookErrorMessage( - this.response, { + this.errorMessage, { Key? key, required this.onReloadClick, }) : super(key: key); - final OrderbookResponse response; + final String errorMessage; final VoidCallback onReloadClick; @override @@ -23,8 +22,8 @@ class _OrderbookErrorMessageState extends State { @override Widget build(BuildContext context) { - final String? error = widget.response.error; - if (error == null) return const SizedBox.shrink(); + final String error = widget.errorMessage; + if (error.isEmpty) return const SizedBox.shrink(); return Center( child: Column( @@ -45,25 +44,24 @@ class _OrderbookErrorMessageState extends State { ), const SizedBox(width: 8), InkWell( - onTap: () => setState(() => _isExpanded = !_isExpanded), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - _isExpanded - ? LocaleKeys.close.tr() - : LocaleKeys.details.tr(), - style: const TextStyle(fontSize: 12), - ), - Icon( - _isExpanded - ? Icons.arrow_drop_up - : Icons.arrow_drop_down, - size: 16, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ], - )), + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _isExpanded + ? LocaleKeys.close.tr() + : LocaleKeys.details.tr(), + style: const TextStyle(fontSize: 12), + ), + Icon( + _isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 16, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ], + ), + ), ], ), if (_isExpanded) diff --git a/lib/views/dex/orderbook/orderbook_view.dart b/lib/views/dex/orderbook/orderbook_view.dart index e9ae5ddba8..50fe0127c4 100644 --- a/lib/views/dex/orderbook/orderbook_view.dart +++ b/lib/views/dex/orderbook/orderbook_view.dart @@ -5,7 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/blocs/orderbook_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/orderbook/order.dart'; import 'package:web_dex/model/orderbook/orderbook.dart'; @@ -66,30 +65,33 @@ class _OrderbookViewState extends State { @override Widget build(BuildContext context) { - return StreamBuilder( + return StreamBuilder( initialData: _model.response, stream: _model.outResponse, builder: (context, snapshot) { if (!_model.isComplete) return const SizedBox.shrink(); - final OrderbookResponse? response = snapshot.data; + final OrderbookResult? result = snapshot.data; - if (response == null) { + if (result == null) { return const Center(child: UiSpinner()); } - if (response.error != null) { + if (result.hasError) { return OrderbookErrorMessage( - response, + result.error ?? LocaleKeys.orderBookFailedLoadError.tr(), onReloadClick: _model.reload, ); } - final Orderbook? orderbook = response.result; - if (orderbook == null) { - return Center( - child: Text(LocaleKeys.orderBookEmpty.tr()), - ); + final response = result.response; + if (response == null) { + return const Center(child: UiSpinner()); + } + + final Orderbook orderbook = Orderbook.fromSdkResponse(response); + if (orderbook.asks.isEmpty && orderbook.bids.isEmpty) { + return Center(child: Text(LocaleKeys.orderBookEmpty.tr())); } return GradientBorder( @@ -97,8 +99,10 @@ class _OrderbookViewState extends State { gradient: dexPageColors.formPlateGradient, child: Container( constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), - padding: - const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/sdk b/sdk index 8626160163..922cc582f9 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 86261601634703681828bfc421f11c7fee68c329 +Subproject commit 922cc582f98b27fc20f0bc81b4fcf1ec23a108a2 From 3a6d3db9da1f86229453af9ed3085e3c07623044 Mon Sep 17 00:00:00 2001 From: Francois Date: Sun, 28 Sep 2025 19:44:20 +0200 Subject: [PATCH 10/24] perf(dex): remove enabled assets rpc call from taker validator With ZHTLC enabled, the `get_enabled_coins` RPC takes long - not yet sure why - and the taker form calls the validator regularly, resulting in long loading times and input delays --- lib/bloc/coins_bloc/coins_bloc.dart | 98 ---------------------- lib/bloc/taker_form/taker_validator.dart | 3 +- lib/model/kdf_auth_metadata_extension.dart | 12 ++- 3 files changed, 13 insertions(+), 100 deletions(-) diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 3277333bf5..3784a0a6e0 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -11,7 +11,6 @@ import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.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'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -127,17 +126,6 @@ class CoinsBloc extends Bloc { ); }, ); - - final coinUpdates = _syncIguanaCoinsStates(); - await emit.forEach( - coinUpdates, - onData: (coin) => - state.copyWith(walletCoins: {...state.walletCoins, coin.id.id: coin}), - onError: (error, stackTrace) { - _log.severe('Error syncing iguana coins states', error, stackTrace); - return state; - }, - ); } Future _onWalletCoinUpdated( @@ -191,18 +179,6 @@ class CoinsBloc extends Bloc { emit(_prePopulateListWithActivatingCoins(event.coinIds)); await _activateCoins(event.coinIds, emit); - final currentWallet = await _kdfSdk.currentWallet(); - if (currentWallet?.config.type == WalletType.iguana || - currentWallet?.config.type == WalletType.hdwallet) { - final coinUpdates = _syncIguanaCoinsStates(); - await emit.forEach( - coinUpdates, - onData: (coin) => state.copyWith( - walletCoins: {...state.walletCoins, coin.id.id: coin}, - ), - ); - } - add(CoinsBalancesRefreshed()); } @@ -394,78 +370,4 @@ class CoinsBloc extends Bloc { coins: {...knownCoins, ...state.coins, ...activatingCoins}, ); } - - /// Yields one coin at a time to provide visual feedback to the user as - /// coins are activated. - /// - /// When multiple coins are found for the provided IDs, - Stream _syncIguanaCoinsStates() async* { - final coinsBlocWalletCoinsState = state.walletCoins; - final previouslyActivatedCoinIds = - (await _kdfSdk.currentWallet())?.config.activatedCoins ?? []; - - final walletAssets = []; - for (final coinId in previouslyActivatedCoinIds) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning( - 'No assets found for activated coin ID: $coinId. ' - 'This coin will be skipped during synchronization.', - ); - continue; - } - if (assets.length > 1) { - final assetIds = assets.map((a) => a.id.id).join(', '); - _log.shout( - 'Multiple assets found for activated coin ID: $coinId. ' - 'Expected single asset, found ${assets.length}: $assetIds. ', - ); - } - - // This is expected to throw if there are multiple assets, to stick - // to the strategy of using `.single` elsewhere in the codebase. - walletAssets.add(assets.single); - } - - final coinsToSync = _getWalletCoinsNotInState( - walletAssets, - coinsBlocWalletCoinsState, - ); - if (coinsToSync.isNotEmpty) { - _log.info( - 'Found ${coinsToSync.length} wallet coins not in state, ' - 'syncing them to state as suspended', - ); - yield* Stream.fromIterable(coinsToSync); - } - } - - List _getWalletCoinsNotInState( - List walletAssets, - Map coinsBlocWalletCoinsState, - ) { - final List coinsToSyncToState = []; - - final enabledAssetsNotInState = walletAssets - .where((asset) => !coinsBlocWalletCoinsState.containsKey(asset.id.id)) - .toList(); - - // Show assets that are in the wallet metadata but not in the state. This might - // happen if activation occurs outside of the coins bloc, like the dex or - // coins manager auto-activation or deactivation. - for (final asset in enabledAssetsNotInState) { - final coin = _coinsRepo.getCoinFromId(asset.id); - if (coin == null) { - _log.shout( - 'Coin ${asset.id.id} not found in coins repository, ' - 'skipping sync from wallet metadata to coins bloc state.', - ); - continue; - } - - coinsToSyncToState.add(coin.copyWith(state: CoinState.suspended)); - } - - return coinsToSyncToState; - } } diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart index e52843a028..8ec8e3a7a7 100644 --- a/lib/bloc/taker_form/taker_validator.dart +++ b/lib/bloc/taker_form/taker_validator.dart @@ -15,6 +15,7 @@ import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dar import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/data_from_service.dart'; import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/shared/utils/formatters.dart'; @@ -221,7 +222,7 @@ class TakerValidator { Future _validateCoinAndParent(String abbr) async { final coin = _sdk.getSdkAsset(abbr); - final enabledAssets = await _sdk.assets.getActivatedAssets(); + final enabledAssets = await _sdk.getWalletAssets(); final isAssetEnabled = enabledAssets.contains(coin); final parentId = coin.id.parentId; final parent = _sdk.assets.available[parentId]; diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 1e1a549a65..202cab7e84 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -41,12 +41,22 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { /// /// Throws [StateError] if multiple assets are found with the same configuration ID. Future> getWalletCoins() async { + final assets = await getWalletAssets(); + return assets + // use single to stick to the existing behaviour around assetByTicker + // which will cause the application to crash if there are + // multiple assets with the same ticker + .map((asset) => asset.toCoin()) + .toList(); + } + + Future> getWalletAssets() async { final coinIds = await getWalletCoinIds(); return coinIds // use single to stick to the existing behaviour around assetByTicker // which will cause the application to crash if there are // multiple assets with the same ticker - .map((coinId) => assets.findAssetsByConfigId(coinId).single.toCoin()) + .map((coinId) => assets.findAssetsByConfigId(coinId).single) .toList(); } From 2f0a129a03feded8a503de2c4e1318cd41d69314 Mon Sep 17 00:00:00 2001 From: Francois Date: Mon, 29 Sep 2025 01:53:50 +0200 Subject: [PATCH 11/24] style(zhtlc): improve dialog layout, add transaction note, fix hidden spinner --- .gitmodules | 2 +- assets/translations/en.json | 1 + lib/bloc/coins_bloc/asset_coin_extension.dart | 4 + .../transaction_history_repo.dart | 6 +- lib/generated/codegen_loader.g.dart | 1 + lib/model/coin_type.dart | 1 + lib/model/coin_utils.dart | 2 + lib/shared/utils/utils.dart | 3 + .../update_interval_dropdown.dart | 1 - .../withdraw_form/withdraw_form.dart | 252 ++++++----- .../zhtlc/zhtlc_activation_status_bar.dart | 12 +- .../zhtlc/zhtlc_configuration_dialog.dart | 421 ++++++++++-------- sdk | 2 +- 13 files changed, 395 insertions(+), 313 deletions(-) diff --git a/.gitmodules b/.gitmodules index f91ac653b3..cb4eb4a2a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "sdk"] path = sdk url = https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - branch = bugfix/orderbook-rpc-schema + branch = bugfix/zhltc-activation-fixes update = checkout fetchRecurseSubmodules = on-demand ignore = dirty diff --git a/assets/translations/en.json b/assets/translations/en.json index b6f1d5d3b0..4065d05ed4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -459,6 +459,7 @@ "withdrawAmountTooLowError": "{} {} too low, you need > {} {} to send", "withdrawNoSuchCoinError": "Invalid selection, {} does not exist", "withdrawPreview": "Preview Withdrawal", + "withdrawPreviewZhtlcNote": "ZHTLC transactions can take a while to generate.\nPlease stay on this page until the preview is ready, otherwise you will need to start over.", "withdrawPreviewError": "Error occurred while fetching withdrawal preview", "txHistoryFetchError": "Error fetching tx history from the endpoint. Unsupported type: {}", "txHistoryNoTransactions": "Transactions are not available", diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 58cc78fb75..1578c980ae 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -96,6 +96,8 @@ extension CoinTypeExtension on CoinSubClass { return CoinType.erc20; case CoinSubClass.krc20: return CoinType.krc20; + case CoinSubClass.zhtlc: + return CoinType.zhtlc; default: return CoinType.utxo; } @@ -166,6 +168,8 @@ extension CoinSubClassExtension on CoinType { return CoinSubClass.erc20; case CoinType.krc20: return CoinSubClass.krc20; + case CoinType.zhtlc: + return CoinSubClass.zhtlc; } } } diff --git a/lib/bloc/transaction_history/transaction_history_repo.dart b/lib/bloc/transaction_history/transaction_history_repo.dart index 19208222c5..755e2e2fc3 100644 --- a/lib/bloc/transaction_history/transaction_history_repo.dart +++ b/lib/bloc/transaction_history/transaction_history_repo.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; /// Throws [TransactionFetchException] if the transaction history could not be /// fetched. @@ -15,6 +16,8 @@ class SdkTransactionHistoryRepository implements TransactionHistoryRepo { required KomodoDefiSdk sdk, }) : _sdk = sdk; final KomodoDefiSdk _sdk; + final Logger _logger = + Logger('SdkTransactionHistoryRepository'); @override Future?> fetch(AssetId assetId, {String? fromId}) async { @@ -39,7 +42,8 @@ class SdkTransactionHistoryRepository implements TransactionHistoryRepo { ), ); return transactionHistory.transactions; - } catch (e) { + } catch (e, s) { + _logger.severe('Failed to fetch transactions for $assetId', e, s); return null; } } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 23227066cd..de11af15bc 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -456,6 +456,7 @@ abstract class LocaleKeys { static const withdrawAmountTooLowError = 'withdrawAmountTooLowError'; static const withdrawNoSuchCoinError = 'withdrawNoSuchCoinError'; static const withdrawPreview = 'withdrawPreview'; + static const withdrawPreviewZhtlcNote = 'withdrawPreviewZhtlcNote'; static const withdrawPreviewError = 'withdrawPreviewError'; static const txHistoryFetchError = 'txHistoryFetchError'; static const txHistoryNoTransactions = 'txHistoryNoTransactions'; diff --git a/lib/model/coin_type.dart b/lib/model/coin_type.dart index b10ac1a6bc..9ee370e8c5 100644 --- a/lib/model/coin_type.dart +++ b/lib/model/coin_type.dart @@ -19,4 +19,5 @@ enum CoinType { tendermintToken, tendermint, slp, + zhtlc, } diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart index 7fee037ab8..d5e997201d 100644 --- a/lib/model/coin_utils.dart +++ b/lib/model/coin_utils.dart @@ -188,6 +188,8 @@ String getCoinTypeName(CoinType type, [String? symbol]) { return 'Tendermint Token'; case CoinType.slp: return 'SLP'; + case CoinType.zhtlc: + return 'ZHTLC'; } } diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index c25a668ff0..6fff9f4bb8 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -392,6 +392,7 @@ final Map _abbr2TickerCache = {}; Color getProtocolColor(CoinType type) { switch (type) { + case CoinType.zhtlc: case CoinType.utxo: return const Color.fromRGBO(233, 152, 60, 1); case CoinType.erc20: @@ -455,6 +456,7 @@ bool hasTxHistorySupport(Coin coin) { case CoinType.hco20: case CoinType.plg20: case CoinType.slp: + case CoinType.zhtlc: return true; } } @@ -471,6 +473,7 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { case CoinType.tendermintToken: return '${coin.explorerUrl}account/$coinAddress'; + case CoinType.zhtlc: case CoinType.utxo: case CoinType.smartChain: case CoinType.erc20: diff --git a/lib/views/market_maker_bot/update_interval_dropdown.dart b/lib/views/market_maker_bot/update_interval_dropdown.dart index ee5e3c99f5..84232cb699 100644 --- a/lib/views/market_maker_bot/update_interval_dropdown.dart +++ b/lib/views/market_maker_bot/update_interval_dropdown.dart @@ -29,7 +29,6 @@ class UpdateIntervalDropdown extends StatelessWidget { child: DropdownButtonFormField( value: interval, onChanged: onChanged, - focusColor: Colors.transparent, items: TradeBotUpdateInterval.values .map( (interval) => DropdownMenuItem( diff --git a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart index cf416e4d04..203b4dd382 100644 --- a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -78,13 +78,13 @@ class _WithdrawFormState extends State { final walletType = authBloc.state.currentUser?.wallet.config.type.name ?? ''; context.read().logEvent( - SendSucceededEventData( - assetSymbol: state.asset.id.id, - network: state.asset.id.subClass.name, - amount: double.tryParse(state.amount) ?? 0.0, - walletType: walletType, - ), - ); + SendSucceededEventData( + assetSymbol: state.asset.id.id, + network: state.asset.id.subClass.name, + amount: double.tryParse(state.amount) ?? 0.0, + walletType: walletType, + ), + ); widget.onSuccess(); }, ), @@ -97,13 +97,13 @@ class _WithdrawFormState extends State { authBloc.state.currentUser?.wallet.config.type.name ?? ''; final reason = state.transactionError?.message ?? 'unknown'; context.read().logEvent( - SendFailedEventData( - assetSymbol: state.asset.id.id, - network: state.asset.protocol.subClass.name, - failReason: reason, - walletType: walletType, - ), - ); + SendFailedEventData( + assetSymbol: state.asset.id.id, + network: state.asset.protocol.subClass.name, + failReason: reason, + walletType: walletType, + ), + ); }, ), BlocListener( @@ -119,7 +119,9 @@ class _WithdrawFormState extends State { message: LocaleKeys.trezorTransactionInProgressMessage.tr(), onCancel: () { Navigator.of(context).pop(); - context.read().add(const WithdrawFormCancelled()); + context.read().add( + const WithdrawFormCancelled(), + ); }, ), ); @@ -143,10 +145,7 @@ class _WithdrawFormState extends State { class WithdrawFormContent extends StatelessWidget { final VoidCallback? onBackButtonPressed; - const WithdrawFormContent({ - this.onBackButtonPressed, - super.key, - }); + const WithdrawFormContent({this.onBackButtonPressed, super.key}); @override Widget build(BuildContext context) { @@ -196,11 +195,7 @@ class NetworkErrorDisplay extends StatelessWidget { final TextError error; final VoidCallback? onRetry; - const NetworkErrorDisplay({ - required this.error, - this.onRetry, - super.key, - }); + const NetworkErrorDisplay({required this.error, this.onRetry, super.key}); @override Widget build(BuildContext context) { @@ -233,10 +228,7 @@ class TransactionErrorDisplay extends StatelessWidget { message: error.message, icon: Icons.warning_amber_rounded, child: onDismiss != null - ? IconButton( - icon: const Icon(Icons.close), - onPressed: onDismiss, - ) + ? IconButton(icon: const Icon(Icons.close), onPressed: onDismiss) : null, ); } @@ -260,10 +252,13 @@ class PreviewWithdrawButton extends StatelessWidget { child: UiPrimaryButton( onPressed: onPressed, child: isSending - ? const SizedBox( + ? SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary, + ), ) : Text(LocaleKeys.withdrawPreview.tr()), ), @@ -271,13 +266,44 @@ class PreviewWithdrawButton extends StatelessWidget { } } +class ZhtlcPreviewDelayNote extends StatelessWidget { + const ZhtlcPreviewDelayNote({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.withdrawPreviewZhtlcNote.tr(), + style: theme.textTheme.bodyMedium?.copyWith( + color: foregroundColor, + ), + ), + ), + ], + ), + ); + } +} + class WithdrawPreviewDetails extends StatelessWidget { final WithdrawalPreview preview; - const WithdrawPreviewDetails({ - required this.preview, - super.key, - }); + const WithdrawPreviewDetails({required this.preview, super.key}); @override Widget build(BuildContext context) { @@ -294,6 +320,10 @@ class WithdrawPreviewDetails extends StatelessWidget { const SizedBox(height: 8), _buildRow(LocaleKeys.fee.tr(), preview.fee.formatTotal()), // Add more preview details as needed + if (preview.memo != null) ...[ + const SizedBox(height: 8), + _buildRow(LocaleKeys.memo.tr(), preview.memo!), + ], ], ), ), @@ -303,10 +333,7 @@ class WithdrawPreviewDetails extends StatelessWidget { Widget _buildRow(String label, String value) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label), - Text(value), - ], + children: [Text(label), Text(value)], ); } } @@ -314,10 +341,7 @@ class WithdrawPreviewDetails extends StatelessWidget { class WithdrawResultDetails extends StatelessWidget { final WithdrawalResult result; - const WithdrawResultDetails({ - required this.result, - super.key, - }); + const WithdrawResultDetails({required this.result, super.key}); @override Widget build(BuildContext context) { @@ -352,8 +376,8 @@ class WithdrawFormFillSection extends StatelessWidget { // Enabled if the asset has multiple source addresses or if there is // no selected address and pubkeys are available. (state.pubkeys?.keys.length ?? 0) > 1 || - (state.selectedSourceAddress == null && - (state.pubkeys?.isNotEmpty ?? false)); + (state.selectedSourceAddress == null && + (state.pubkeys?.isNotEmpty ?? false)); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -365,22 +389,22 @@ class WithdrawFormFillSection extends StatelessWidget { isLoading: state.pubkeys?.isEmpty ?? true, onChanged: isSourceInputEnabled ? (address) => address == null - ? null - : context - .read() - .add(WithdrawFormSourceChanged(address)) + ? null + : context.read().add( + WithdrawFormSourceChanged(address), + ) : null, ), const SizedBox(height: 16), RecipientAddressWithNotification( address: state.recipientAddress, isMixedAddress: state.isMixedCaseAddress, - onChanged: (value) => context - .read() - .add(WithdrawFormRecipientChanged(value)), - onQrScanned: (value) => context - .read() - .add(WithdrawFormRecipientChanged(value)), + onChanged: (value) => context.read().add( + WithdrawFormRecipientChanged(value), + ), + onQrScanned: (value) => context.read().add( + WithdrawFormRecipientChanged(value), + ), errorText: state.recipientAddressError == null ? null : () => state.recipientAddressError?.message, @@ -398,12 +422,12 @@ class WithdrawFormFillSection extends StatelessWidget { asset: state.asset, amount: state.amount, isMaxAmount: state.isMaxAmount, - onChanged: (value) => context - .read() - .add(WithdrawFormAmountChanged(value)), - onMaxToggled: (value) => context - .read() - .add(WithdrawFormMaxAmountEnabled(value)), + onChanged: (value) => context.read().add( + WithdrawFormAmountChanged(value), + ), + onMaxToggled: (value) => context.read().add( + WithdrawFormMaxAmountEnabled(value), + ), amountError: state.amountError?.message, ), if (state.isCustomFeeSupported) ...[ @@ -427,9 +451,9 @@ class WithdrawFormFillSection extends StatelessWidget { selectedFee: state.customFee!, isCustomFee: true, // indicates user can edit it onFeeSelected: (newFee) { - context - .read() - .add(WithdrawFormCustomFeeChanged(newFee!)); + context.read().add( + WithdrawFormCustomFeeChanged(newFee!), + ); }, ), @@ -449,11 +473,11 @@ class WithdrawFormFillSection extends StatelessWidget { ], const SizedBox(height: 16), if (_isMemoSupportedProtocol(state.asset)) ...[ - WithdrawMemoField( - memo: state.memo, - onChanged: (value) => context - .read() - .add(WithdrawFormMemoChanged(value)), + WithdrawMemoField( + memo: state.memo, + onChanged: (value) => context.read().add( + WithdrawFormMemoChanged(value), + ), ), ], const SizedBox(height: 24), @@ -472,21 +496,26 @@ class WithdrawFormFillSection extends StatelessWidget { final authBloc = context.read(); final walletType = authBloc.state.currentUser?.wallet.config.type.name ?? - ''; + ''; context.read().logEvent( - SendInitiatedEventData( - assetSymbol: state.asset.id.id, - network: state.asset.protocol.subClass.name, - amount: double.tryParse(state.amount) ?? 0.0, - walletType: walletType, - ), - ); - context - .read() - .add(const WithdrawFormPreviewSubmitted()); + SendInitiatedEventData( + assetSymbol: state.asset.id.id, + network: state.asset.protocol.subClass.name, + amount: double.tryParse(state.amount) ?? 0.0, + walletType: walletType, + ), + ); + context.read().add( + const WithdrawFormPreviewSubmitted(), + ); }, isSending: state.isSending, ), + if (state.asset.id.subClass == CoinSubClass.zhtlc && + state.isSending) ...[ + const SizedBox(height: 12), + const ZhtlcPreviewDelayNote(), + ], ], ); }, @@ -514,9 +543,9 @@ class WithdrawFormConfirmSection extends StatelessWidget { children: [ Expanded( child: OutlinedButton( - onPressed: () => context - .read() - .add(const WithdrawFormCancelled()), + onPressed: () => context.read().add( + const WithdrawFormCancelled(), + ), child: Text(LocaleKeys.back.tr()), ), ), @@ -526,9 +555,9 @@ class WithdrawFormConfirmSection extends StatelessWidget { onPressed: state.isSending ? null : () { - context - .read() - .add(const WithdrawFormSubmitted()); + context.read().add( + const WithdrawFormSubmitted(), + ); }, child: state.isSending ? const SizedBox( @@ -644,19 +673,13 @@ class WithdrawResultCard extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - LocaleKeys.network.tr(), - style: theme.textTheme.titleMedium, - ), + Text(LocaleKeys.network.tr(), style: theme.textTheme.titleMedium), const SizedBox(height: 8), Row( children: [ AssetLogo.ofId(asset.id), const SizedBox(width: 8), - Text( - asset.id.name, - style: theme.textTheme.bodyLarge, - ), + Text(asset.id.name, style: theme.textTheme.bodyLarge), ], ), ], @@ -675,11 +698,7 @@ class WithdrawFormFailedSection extends StatelessWidget { builder: (context, state) { return Column( children: [ - Icon( - Icons.error_outline, - size: 64, - color: theme.colorScheme.error, - ), + Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error), const SizedBox(height: 24), Text( LocaleKeys.transactionFailed.tr(), @@ -690,24 +709,22 @@ class WithdrawFormFailedSection extends StatelessWidget { ), const SizedBox(height: 24), if (state.transactionError != null) - WithdrawErrorCard( - error: state.transactionError!, - ), + WithdrawErrorCard(error: state.transactionError!), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ OutlinedButton( - onPressed: () => context - .read() - .add(const WithdrawFormStepReverted()), + onPressed: () => context.read().add( + const WithdrawFormStepReverted(), + ), child: Text(LocaleKeys.back.tr()), ), const SizedBox(width: 16), FilledButton( - onPressed: () => context - .read() - .add(const WithdrawFormReset()), + onPressed: () => context.read().add( + const WithdrawFormReset(), + ), child: Text(LocaleKeys.tryAgain.tr()), ), ], @@ -722,10 +739,7 @@ class WithdrawFormFailedSection extends StatelessWidget { class WithdrawErrorCard extends StatelessWidget { final BaseError error; - const WithdrawErrorCard({ - required this.error, - super.key, - }); + const WithdrawErrorCard({required this.error, super.key}); @override Widget build(BuildContext context) { @@ -742,10 +756,7 @@ class WithdrawErrorCard extends StatelessWidget { style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), - SelectableText( - error.message, - style: theme.textTheme.bodyMedium, - ), + SelectableText(error.message, style: theme.textTheme.bodyMedium), if (error is TextError) ...[ const SizedBox(height: 16), const Divider(), @@ -855,8 +866,10 @@ class _RecipientAddressWithNotificationState opacity: 1.0, child: Container( width: double.infinity, - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 12, + ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), @@ -864,8 +877,9 @@ class _RecipientAddressWithNotificationState alignment: Alignment.center, child: Text( LocaleKeys.addressConvertedToMixedCase.tr(), - style: - theme.textTheme.labelMedium?.copyWith(color: statusColor), + style: theme.textTheme.labelMedium?.copyWith( + color: statusColor, + ), ), ), ), diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index 7f079ce727..e88313b0ce 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -119,7 +119,7 @@ class _ZhtlcActivationStatusBarState extends State { ), ), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), @@ -127,8 +127,8 @@ class _ZhtlcActivationStatusBarState extends State { child: Row( children: [ SizedBox( - width: 16, - height: 16, + width: 14, + height: 14, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( @@ -143,9 +143,9 @@ class _ZhtlcActivationStatusBarState extends State { coinCount, args: [coinNames], ), - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).textTheme.titleSmall?.color, - fontWeight: FontWeight.w600, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + fontWeight: FontWeight.w500, ), ), ), diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart index 2dcb740e5e..adcf79f8f2 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -18,6 +19,8 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +enum ZhtlcSyncType { earliest, height, date } + /// Shows ZHTLC configuration dialog similar to handleZhtlcConfigDialog from SDK example /// This is bad practice (UI logic in utils), but necessary for now because of /// auto-coin activations from multiple sources in BLoCs. @@ -27,16 +30,13 @@ Future confirmZhtlcConfiguration( }) async { String? prefilledZcashPath; - // On desktop platforms, try to download Zcash parameters first if (ZcashParamsDownloaderFactory.requiresDownload) { ZcashParamsDownloader? downloader; try { downloader = ZcashParamsDownloaderFactory.create(); - // Check if parameters are already available final areAvailable = await downloader.areParamsAvailable(); if (!areAvailable) { - // Show download progress dialog final downloadResult = await _showZcashDownloadDialog( context, downloader, @@ -50,7 +50,6 @@ Future confirmZhtlcConfiguration( prefilledZcashPath = await downloader.getParamsPath(); } catch (e) { - // Error creating downloader or getting params path if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -60,7 +59,6 @@ Future confirmZhtlcConfiguration( ); } } finally { - // Always dispose the downloader to release resources downloader?.dispose(); } } @@ -95,12 +93,10 @@ class _ZhtlcConfigurationDialogState extends State { late final TextEditingController zcashPathController; late final TextEditingController blocksPerIterController; late final TextEditingController intervalMsController; - late final TextEditingController syncValueController; StreamSubscription? _authSubscription; bool _dismissedDueToAuthChange = false; - String syncType = 'date'; - DateTime? selectedDateTime; + final GlobalKey<_SyncFormState> _syncFormKey = GlobalKey<_SyncFormState>(); @override void initState() { @@ -113,11 +109,6 @@ class _ZhtlcConfigurationDialogState extends State { zcashPathController = TextEditingController(text: defaultZcashPath); blocksPerIterController = TextEditingController(text: '1000'); intervalMsController = TextEditingController(text: '0'); - syncValueController = TextEditingController(); - - // Initialize with default date (2 days ago) - selectedDateTime = DateTime.now().subtract(const Duration(days: 2)); - syncValueController.text = formatDate(selectedDateTime!); _subscribeToAuthChanges(); } @@ -128,157 +119,9 @@ class _ZhtlcConfigurationDialogState extends State { zcashPathController.dispose(); blocksPerIterController.dispose(); intervalMsController.dispose(); - syncValueController.dispose(); super.dispose(); } - String formatDate(DateTime dateTime) { - return dateTime.toIso8601String().split('T')[0]; - } - - /// Creates a Material 3 theme for the date picker based on the current Material 2 theme - ThemeData _createMaterial3DatePickerTheme(BuildContext context) { - final currentTheme = Theme.of(context); - final currentColorScheme = currentTheme.colorScheme; - - // Use the current theme's primary color as the seed color - // This works for both light and dark themes since primary is set appropriately in each - final material3ColorScheme = ColorScheme.fromSeed( - seedColor: currentColorScheme.primary, - brightness: currentColorScheme.brightness, - ); - - return ThemeData( - useMaterial3: true, - colorScheme: material3ColorScheme, - fontFamily: currentTheme.textTheme.bodyMedium?.fontFamily, - ); - } - - Future _selectDate() async { - final picked = await showDatePicker( - context: context, - initialDate: selectedDateTime ?? DateTime.now(), - firstDate: DateTime(2018), // first arrr block in 2018 - lastDate: DateTime.now(), - builder: (context, child) { - return Theme( - data: _createMaterial3DatePickerTheme(context), - child: child ?? const SizedBox(), - ); - }, - ); - - if (picked != null) { - setState(() { - selectedDateTime = DateTime(picked.year, picked.month, picked.day); - syncValueController.text = formatDate(selectedDateTime!); - }); - } - } - - void _onSyncTypeChanged(String? newSyncType) { - if (newSyncType == null) return; - setState(() { - syncType = newSyncType; - // Clear the input when switching sync types - if (syncType == 'date') { - // Set default date (2 days ago) for date type - selectedDateTime = DateTime.now().subtract(const Duration(days: 2)); - syncValueController.text = formatDate(selectedDateTime!); - } else if (syncType == 'height') { - // Clear input for block height - syncValueController.clear(); - } else { - // Clear input for earliest (no input needed) - syncValueController.clear(); - } - }); - } - - Widget _buildSyncForm() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(LocaleKeys.zhtlcStartSyncFromLabel.tr()), - const SizedBox(width: 12), - DropdownButton( - value: syncType, - items: [ - DropdownMenuItem( - value: 'earliest', - child: Text(LocaleKeys.zhtlcEarliestSaplingOption.tr()), - ), - DropdownMenuItem( - value: 'height', - child: Text(LocaleKeys.zhtlcBlockHeightOption.tr()), - ), - DropdownMenuItem( - value: 'date', - child: Text(LocaleKeys.zhtlcDateTimeOption.tr()), - ), - ], - onChanged: _onSyncTypeChanged, - ), - ], - ), - if (syncType != 'earliest') ...[ - const SizedBox(height: 12), - TextField( - controller: syncValueController, - decoration: InputDecoration( - labelText: syncType == 'height' - ? LocaleKeys.zhtlcBlockHeightOption.tr() - : LocaleKeys.zhtlcSelectDateTimeLabel.tr(), - suffixIcon: syncType == 'date' - ? IconButton( - icon: const Icon(Icons.calendar_today), - onPressed: _selectDate, - ) - : null, - ), - keyboardType: syncType == 'height' - ? TextInputType.number - : TextInputType.none, - readOnly: syncType == 'date', - onTap: syncType == 'date' ? _selectDate : null, - ), - if (syncType == 'date') ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - margin: const EdgeInsets.only(top: 4.0), - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 12.0, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - ), - ), - child: Text( - LocaleKeys.zhtlcDateSyncHint.tr(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - textAlign: TextAlign.center, - ), - ), - ], - ], - ], - ); - } - void _handleSave() { final path = zcashPathController.text.trim(); // On web, allow empty path, otherwise require it @@ -290,28 +133,10 @@ class _ZhtlcConfigurationDialogState extends State { } // Create sync params based on type - ZhtlcSyncParams? syncParams; - if (syncType == 'earliest') { - syncParams = ZhtlcSyncParams.earliest(); - } else if (syncType == 'height') { - final v = int.tryParse(syncValueController.text.trim()); - if (v == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(LocaleKeys.zhtlcInvalidBlockHeight.tr())), - ); - return; - } - syncParams = ZhtlcSyncParams.height(v); - } else if (syncType == 'date') { - if (selectedDateTime == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(LocaleKeys.zhtlcSelectDateTimeRequired.tr())), - ); - return; - } - // Convert to Unix timestamp (seconds since epoch) - final unixTimestamp = selectedDateTime!.millisecondsSinceEpoch ~/ 1000; - syncParams = ZhtlcSyncParams.date(unixTimestamp); + final syncState = _syncFormKey.currentState; + final syncParams = syncState?.buildSyncParams(); + if (syncParams == null) { + return; } final result = ZhtlcUserConfig( @@ -364,7 +189,7 @@ class _ZhtlcConfigurationDialogState extends State { keyboardType: TextInputType.number, ), const SizedBox(height: 12), - _buildSyncForm(), + _SyncForm(key: _syncFormKey), ], ), ), @@ -396,6 +221,234 @@ class _ZhtlcConfigurationDialogState extends State { } } +class _SyncForm extends StatefulWidget { + const _SyncForm({super.key}); + + @override + State<_SyncForm> createState() => _SyncFormState(); +} + +class _SyncFormState extends State<_SyncForm> { + late final TextEditingController _syncValueController; + ZhtlcSyncType _syncType = ZhtlcSyncType.date; + DateTime? _selectedDate; + + @override + void initState() { + super.initState(); + _selectedDate = DateTime.now().subtract(const Duration(days: 2)); + _syncValueController = TextEditingController( + text: _formatDate(_selectedDate!), + ); + } + + @override + void dispose() { + _syncValueController.dispose(); + super.dispose(); + } + + ZhtlcSyncParams? buildSyncParams() { + switch (_syncType) { + case ZhtlcSyncType.earliest: + return ZhtlcSyncParams.earliest(); + case ZhtlcSyncType.height: + final rawValue = _syncValueController.text.trim(); + final parsedValue = int.tryParse(rawValue); + if (parsedValue == null) { + _showSnackBar(LocaleKeys.zhtlcInvalidBlockHeight.tr()); + return null; + } + return ZhtlcSyncParams.height(parsedValue); + case ZhtlcSyncType.date: + if (_selectedDate == null) { + return null; + } + final unixTimestamp = _selectedDate!.millisecondsSinceEpoch ~/ 1000; + return ZhtlcSyncParams.date(unixTimestamp); + } + } + + void _showSnackBar(String message) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime.now(), + builder: (context, child) { + return Theme( + data: _createMaterial3DatePickerTheme(), + child: child ?? const SizedBox(), + ); + }, + ); + + if (picked != null) { + setState(() { + _selectedDate = DateTime(picked.year, picked.month, picked.day); + _syncValueController.text = _formatDate(_selectedDate!); + }); + } + } + + void _onSyncTypeChanged(ZhtlcSyncType? newType) { + if (newType == null) { + return; + } + + setState(() { + _syncType = newType; + if (_syncType == ZhtlcSyncType.date) { + _selectedDate = DateTime.now().subtract(const Duration(days: 2)); + _syncValueController.text = _formatDate(_selectedDate!); + } else { + _selectedDate = null; + _syncValueController.clear(); + } + }); + } + + String _formatDate(DateTime dateTime) { + return dateTime.toIso8601String().split('T')[0]; + } + + ThemeData _createMaterial3DatePickerTheme() { + final currentTheme = Theme.of(context); + final currentColorScheme = currentTheme.colorScheme; + + final material3ColorScheme = ColorScheme.fromSeed( + seedColor: currentColorScheme.primary, + brightness: currentColorScheme.brightness, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: material3ColorScheme, + fontFamily: currentTheme.textTheme.bodyMedium?.fontFamily, + ); + } + + String _syncTypeLabel(ZhtlcSyncType type) { + switch (type) { + case ZhtlcSyncType.earliest: + return LocaleKeys.zhtlcEarliestSaplingOption.tr(); + case ZhtlcSyncType.height: + return LocaleKeys.zhtlcBlockHeightOption.tr(); + case ZhtlcSyncType.date: + return LocaleKeys.zhtlcDateTimeOption.tr(); + } + } + + bool get _shouldShowValueField => _syncType != ZhtlcSyncType.earliest; + + bool get _isDate => _syncType == ZhtlcSyncType.date; + + bool get _isHeight => _syncType == ZhtlcSyncType.height; + + @override + Widget build(BuildContext context) { + final dropdownItems = ZhtlcSyncType.values + .map( + (type) => DropdownMenuItem( + value: type, + alignment: Alignment.centerLeft, + child: Text(_syncTypeLabel(type)), + ), + ) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(LocaleKeys.zhtlcStartSyncFromLabel.tr()), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: DropdownButtonFormField( + initialValue: _syncType, + items: dropdownItems, + onChanged: _onSyncTypeChanged, + ), + ), + if (_shouldShowValueField) ...[ + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _syncValueController, + decoration: InputDecoration( + labelText: _isHeight + ? LocaleKeys.zhtlcBlockHeightOption.tr() + : LocaleKeys.zhtlcSelectDateTimeLabel.tr(), + suffixIcon: _isDate + ? IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate, + ) + : null, + ), + keyboardType: _isHeight + ? TextInputType.number + : TextInputType.none, + readOnly: _isDate, + onTap: _isDate ? () => _selectDate() : null, + ), + ), + ], + ], + ), + if (_shouldShowValueField) ...[ + const SizedBox(height: 24), + if (_isDate) ...[const _SyncTimeWarning()], + ], + ], + ); + } +} + +class _SyncTimeWarning extends StatelessWidget { + const _SyncTimeWarning(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final backgroundColor = theme.colorScheme.secondaryContainer; + final foregroundColor = theme.colorScheme.onSecondaryContainer; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.1), + border: Border.all(color: foregroundColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + LocaleKeys.zhtlcDateSyncHint.tr(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: foregroundColor), + ), + ), + ], + ), + ); + } +} + /// Shows a download progress dialog for Zcash parameters Future _showZcashDownloadDialog( BuildContext context, diff --git a/sdk b/sdk index 922cc582f9..ec445d35ba 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 922cc582f98b27fc20f0bc81b4fcf1ec23a108a2 +Subproject commit ec445d35ba05fe6754f9687b90cdd33750985b6b From a264209d4f041f2024be30fce103a59691f3bf36 Mon Sep 17 00:00:00 2001 From: Francois Date: Mon, 29 Sep 2025 03:12:31 +0200 Subject: [PATCH 12/24] fix(market-metrics): filter out inactive coins before starting calcs fetching tx history of activating coins wrecks havok on startup, especially for ARRR --- .../bloc/asset_overview_bloc.dart | 10 +++- .../portfolio_growth_bloc.dart | 55 +++++++++++-------- .../profit_loss/profit_loss_bloc.dart | 18 ++---- lib/bloc/coins_bloc/asset_coin_extension.dart | 31 ++++++++--- .../wallet_page/wallet_main/wallet_main.dart | 22 +++----- sdk | 2 +- 6 files changed, 75 insertions(+), 63 deletions(-) diff --git a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart index 5182b22023..2332171718 100644 --- a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -9,6 +9,7 @@ import 'package:web_dex/bloc/cex_market_data/profit_loss/models/fiat_value.dart' import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; part 'asset_overview_event.dart'; @@ -95,7 +96,12 @@ class AssetOverviewBloc extends Bloc { await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); - final profitLossesFutures = event.coins.map((coin) async { + final activeCoins = await event.coins.removeInactiveCoins(_sdk); + if (activeCoins.isEmpty) { + return; + } + + final profitLossesFutures = activeCoins.map((coin) async { // Catch errors that occur for single coins and exclude them from the // total so that transaction fetching errors for a single coin do not // affect the total investment calculation. @@ -114,7 +120,7 @@ class AssetOverviewBloc extends Bloc { final profitLosses = await Future.wait(profitLossesFutures); final totalInvestment = await _investmentRepository - .calculateTotalInvestment(event.walletId, event.coins); + .calculateTotalInvestment(event.walletId, activeCoins); final profitAmount = profitLosses.fold(0.0, (sum, item) { return sum + (item.lastOrNull?.profitLoss ?? 0.0); diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index 0082d84a3f..e16efd10b6 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -10,6 +10,7 @@ import 'package:rational/rational.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; @@ -55,8 +56,13 @@ class PortfolioGrowthBloc PortfolioGrowthPeriodChanged event, Emitter emit, ) { - final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = - _calculateCoinProgressCounters(event.coins); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + event.coins, + ); final currentState = state; if (currentState is PortfolioGrowthChartLoadSuccess) { emit( @@ -120,7 +126,9 @@ class PortfolioGrowthBloc int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, - ) = _calculateCoinProgressCounters(event.coins); + ) = _calculateCoinProgressCounters( + event.coins, + ); return emit( PortfolioGrowthChartUnsupported( selectedPeriod: event.selectedPeriod, @@ -131,8 +139,9 @@ class PortfolioGrowthBloc ); } + final initialActiveCoins = await coins.removeInactiveCoins(_sdk); await _loadChart( - coins, + initialActiveCoins, event, useCache: true, ).then(emit.call).catchError((Object error, StackTrace stackTrace) { @@ -149,7 +158,7 @@ class PortfolioGrowthBloc // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. - final activeCoins = await _removeInactiveCoins(coins); + final activeCoins = await coins.removeInactiveCoins(_sdk); if (activeCoins.isNotEmpty) { await _loadChart( activeCoins, @@ -190,7 +199,9 @@ class PortfolioGrowthBloc int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, - ) = _calculateCoinProgressCounters(event.coins); + ) = _calculateCoinProgressCounters( + event.coins, + ); emit( GrowthChartLoadFailure( error: TextError(error: 'Failed to load portfolio growth'), @@ -238,8 +249,13 @@ class PortfolioGrowthBloc final totalChange24h = await _calculateTotalChange24h(coins); final percentageChange24h = await _calculatePercentageChange24h(coins); - final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = - _calculateCoinProgressCounters(event.coins); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + event.coins, + ); return PortfolioGrowthChartLoadSuccess( portfolioGrowth: chart, @@ -261,7 +277,7 @@ class PortfolioGrowthBloc // Do not let transaction loading exceptions stop the periodic updates try { final supportedCoins = await _removeUnsupportedCoins(event); - final coins = await _removeInactiveCoins(supportedCoins); + final coins = await supportedCoins.removeInactiveCoins(_sdk); return await _portfolioGrowthRepository.getPortfolioGrowthChart( coins, fiatCoinId: event.fiatCoinId, @@ -274,18 +290,6 @@ class PortfolioGrowthBloc } } - Future> _removeInactiveCoins(List coins) async { - final coinsCopy = List.of(coins); - final activeCoins = await _sdk.assets.getActivatedAssets(); - final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); - for (final coin in coins) { - if (!activeCoinsMap.contains(coin.id)) { - coinsCopy.remove(coin); - } - } - return coinsCopy; - } - Future _handlePortfolioGrowthUpdate( ChartData growthChart, Duration selectedPeriod, @@ -300,8 +304,13 @@ class PortfolioGrowthBloc final totalChange24h = await _calculateTotalChange24h(coins); final percentageChange24h = await _calculatePercentageChange24h(coins); - final (int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat) = - _calculateCoinProgressCounters(coins); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + coins, + ); return PortfolioGrowthChartLoadSuccess( portfolioGrowth: growthChart, diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart index 080e547c1b..ad14866c6c 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart @@ -9,6 +9,7 @@ import 'package:logging/logging.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; @@ -51,6 +52,7 @@ class ProfitLossBloc extends Bloc { event.coins, event.fiatCoinId, ); + final initialActiveCoins = await supportedCoins.removeInactiveCoins(_sdk); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. if (supportedCoins.isEmpty && event.coins.length <= 1) { @@ -63,7 +65,7 @@ class ProfitLossBloc extends Bloc { await _getProfitLossChart( event, - supportedCoins, + initialActiveCoins, useCache: true, ).then(emit.call).catchError((Object error, StackTrace stackTrace) { const errorMessage = 'Failed to load CACHED portfolio profit/loss'; @@ -74,7 +76,7 @@ class ProfitLossBloc extends Bloc { // Fetch the un-cached version of the chart to update the cache. await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); - final activeCoins = await _removeInactiveCoins(supportedCoins); + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isNotEmpty) { await _getProfitLossChart( event, @@ -232,16 +234,4 @@ class ProfitLossBloc extends Bloc { chartsList.removeWhere((element) => element.isEmpty); return Charts.merge(chartsList)..sort((a, b) => a.x.compareTo(b.x)); } - - Future> _removeInactiveCoins(List coins) async { - final coinsCopy = List.of(coins); - final activeCoins = await _sdk.assets.getActivatedAssets(); - final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); - for (final coin in coins) { - if (!activeCoinsMap.contains(coin.id)) { - coinsCopy.remove(coin); - } - } - return coinsCopy; - } } diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 1578c980ae..823b9593b7 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -5,6 +5,7 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/shared/utils/extensions/collection_extensions.dart'; extension AssetCoinExtension on Asset { Coin toCoin() { @@ -15,7 +16,7 @@ extension AssetCoinExtension on Asset { final logoImageUrl = config.valueOrNull('logo_image_url'); final isCustomToken = (config.valueOrNull('is_custom_token') ?? false) || - logoImageUrl != null; + logoImageUrl != null; final ProtocolData protocolData = ProtocolData( platform: id.parentId?.id ?? platform ?? '', @@ -38,8 +39,9 @@ extension AssetCoinExtension on Asset { isTestCoin: protocol.isTestnet, coingeckoId: id.symbol.coinGeckoId, swapContractAddress: config.valueOrNull('swap_contract_address'), - fallbackSwapContract: - config.valueOrNull('fallback_swap_contract'), + fallbackSwapContract: config.valueOrNull( + 'fallback_swap_contract', + ), priority: priorityCoinsAbbrMap[id.id] ?? 0, state: CoinState.inactive, walletOnly: config.valueOrNull('wallet_only') ?? false, @@ -49,8 +51,11 @@ extension AssetCoinExtension on Asset { ); } - String? get contractAddress => protocol.config - .valueOrNull('protocol', 'protocol_data', 'contract_address'); + String? get contractAddress => protocol.config.valueOrNull( + 'protocol', + 'protocol_data', + 'contract_address', + ); String? get platform => protocol.config.valueOrNull('protocol', 'protocol_data', 'platform'); } @@ -205,10 +210,7 @@ extension AssetBalanceExtension on Coin { KomodoDefiSdk sdk, { bool activateIfNeeded = true, }) { - return sdk.balances.watchBalance( - id, - activateIfNeeded: activateIfNeeded, - ); + return sdk.balances.watchBalance(id, activateIfNeeded: activateIfNeeded); } /// Get the last-known balance for this coin. @@ -231,3 +233,14 @@ extension AssetBalanceExtension on Coin { return (balance * price).spendable.toDouble(); } } + +extension CoinListOps on List { + Future> removeInactiveCoins(KomodoDefiSdk sdk) async { + final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + + return where( + (coin) => activeCoinsMap.contains(coin.id), + ).unmodifiable().toList(); + } +} diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index a9b4b65c96..01f72c75a0 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -234,20 +234,14 @@ class _WalletMainState extends State with TickerProviderStateMixin { ), ); - assetOverviewBloc - ..add( - PortfolioAssetsOverviewLoadRequested( - coins: walletCoins, - walletId: walletId, - ), - ) - ..add( - PortfolioAssetsOverviewSubscriptionRequested( - coins: walletCoins, - walletId: walletId, - updateFrequency: const Duration(minutes: 1), - ), - ); + // Subscribe fires an immediate load event, so no need to also call load + assetOverviewBloc.add( + PortfolioAssetsOverviewSubscriptionRequested( + coins: walletCoins, + walletId: walletId, + updateFrequency: const Duration(minutes: 1), + ), + ); } void _clearWalletData() { diff --git a/sdk b/sdk index ec445d35ba..e6e924c550 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit ec445d35ba05fe6754f9687b90cdd33750985b6b +Subproject commit e6e924c5504b8edd41381ac116b34a4780750e79 From ccfae6845cf48cc10a8c1c25b8f6f8e0b53752d2 Mon Sep 17 00:00:00 2001 From: Francois Date: Mon, 29 Sep 2025 04:21:46 +0200 Subject: [PATCH 13/24] fix(zhtlc): workaround "Task is finished" error with retry on activation Activation throws a bunch of uncaught errors, takes, long, but with this the coin should not remain in suspended state when it does finally activate. TODO(takenagain): investigate and fix cause in SDK --- .../arrr_activation_service.dart | 99 +++++++++++++------ 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index 846ab92ecd..b2dc88af48 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart' + show ExponentialBackoff, retry; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -116,36 +118,68 @@ class ArrrActivationService { Asset asset, ZhtlcUserConfig config, ) async { - try { - _cacheActivationStart(asset.id); - - ActivationProgress? lastActivationProgress; - await for (final activationProgress in _sdk.assets.activateAsset(asset)) { - _cacheActivationProgress(asset.id, activationProgress); - lastActivationProgress = activationProgress; - } + const maxAttempts = 3; + var attempt = 0; - _cacheActivationComplete(asset.id); - - // return result type by status of activation - if (lastActivationProgress?.isSuccess ?? false) { - return ArrrActivationResultSuccess( - Stream.value( - ActivationProgress( - status: 'Activation completed successfully', - progressDetails: ActivationProgressDetails( - currentStep: ActivationStep.complete, - stepCount: 1, + try { + final result = await retry( + () async { + attempt += 1; + _log.info( + 'Starting ARRR activation attempt $attempt for ${asset.id.id}', + ); + + _cacheActivationStart(asset.id); + + ActivationProgress? lastActivationProgress; + await for (final activationProgress in _sdk.assets.activateAsset( + asset, + )) { + _cacheActivationProgress(asset.id, activationProgress); + lastActivationProgress = activationProgress; + } + + if (lastActivationProgress?.isSuccess ?? false) { + _cacheActivationComplete(asset.id); + return ArrrActivationResultSuccess( + Stream.value( + ActivationProgress( + status: 'Activation completed successfully', + progressDetails: ActivationProgressDetails( + currentStep: ActivationStep.complete, + stepCount: 1, + ), + ), ), - ), - ), - ); - } else { - return ArrrActivationResultError( - lastActivationProgress?.errorMessage ?? 'Unknown activation error', - ); - } - } catch (e) { + ); + } + + final errorMessage = + lastActivationProgress?.errorMessage ?? + 'Unknown activation error'; + throw _RetryableZhtlcActivationException(errorMessage); + }, + maxAttempts: maxAttempts, + backoffStrategy: ExponentialBackoff( + initialDelay: const Duration(seconds: 1), + maxDelay: const Duration(seconds: 5), + ), + shouldRetry: (error) => error is _RetryableZhtlcActivationException, + onRetry: (currentAttempt, error, delay) { + _log.warning( + 'ARRR activation attempt $currentAttempt for ${asset.id.id} failed. ' + 'Retrying in ${delay.inMilliseconds}ms. Error: $error', + ); + }, + ); + + return result; + } catch (e, stackTrace) { + _log.severe( + 'ARRR activation failed after $maxAttempts attempts for ${asset.id.id}', + e, + stackTrace, + ); _cacheActivationError(asset.id, e.toString()); return ArrrActivationResultError(e.toString()); } @@ -383,6 +417,15 @@ class ArrrActivationService { } } +class _RetryableZhtlcActivationException implements Exception { + const _RetryableZhtlcActivationException(this.message); + + final String message; + + @override + String toString() => 'RetryableZhtlcActivationException: $message'; +} + /// Configuration request model for UI handling class ZhtlcConfigurationRequest { const ZhtlcConfigurationRequest({ From 6968034b5a3bb53a9299cf2c387abbd7fc2ea0fa Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 30 Sep 2025 02:14:20 +0200 Subject: [PATCH 14/24] feat(zhtlc): add activation status to banner --- assets/translations/en.json | 4 +- lib/bloc/coins_bloc/coins_repo.dart | 19 +- .../arrr_activation_service.dart | 124 ++++++++----- .../zhtlc/zhtlc_activation_status_bar.dart | 169 ++++++++++++++---- pubspec.lock | 2 +- pubspec.yaml | 1 + sdk | 2 +- 7 files changed, 226 insertions(+), 95 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 4065d05ed4..cde938009d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -784,7 +784,7 @@ "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", "zhtlcDateSyncHint": "Selecting a date further in the past can significantly increase the activation time. \nActivation can take a little while the first time to download block cache data.\n\nTransactions and balance prior to the sync date may be missing.\nOften this can be restored by sending in and out new transactions", "activatingZhtlcCoins": { - "one": "Activating ZHTLC coin: {}. Please do not close the app or tab until complete.", - "other": "Activating ZHTLC coins: {}. Please do not close the app or tab until complete." + "one": "Activating ZHTLC coin. Please do not close the app or tab until complete.", + "other": "Activating ZHTLC coins. Please do not close the app or tab until complete." } } \ No newline at end of file diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 73d4426979..cc2bd6d814 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -770,20 +770,19 @@ class CoinsRepo { // before proceeding with activation, and doesn't broadcast activation status // until config parameters are received and (desktop) params files downloaded. final result = await _arrrActivationService.activateArrr(asset); - - // Add assets after activation regardless of success or failure - if (addToWalletMetadata) { - await _addAssetsToWalletMetdata([asset.id]); - } - - if (notifyListeners) { - _broadcastAsset(coin.copyWith(state: CoinState.activating)); - } - result.when( success: (progress) async { _log.info('ZHTLC asset activated successfully: ${asset.id.id}'); + // Add assets after activation regardless of success or failure + if (addToWalletMetadata) { + await _addAssetsToWalletMetdata([asset.id]); + } + + if (notifyListeners) { + _broadcastAsset(coin.copyWith(state: CoinState.activating)); + } + if (notifyListeners) { _broadcastAsset(coin.copyWith(state: CoinState.active)); if (coin.id.parentId != null) { diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index b2dc88af48..bf78158d94 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -5,7 +5,7 @@ import 'package:komodo_defi_types/komodo_defi_type_utils.dart' show ExponentialBackoff, retry; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; -import 'package:web_dex/shared/utils/utils.dart'; +import 'package:mutex/mutex.dart'; import 'arrr_config.dart'; @@ -118,7 +118,7 @@ class ArrrActivationService { Asset asset, ZhtlcUserConfig config, ) async { - const maxAttempts = 3; + const maxAttempts = 5; var attempt = 0; try { @@ -129,22 +129,24 @@ class ArrrActivationService { 'Starting ARRR activation attempt $attempt for ${asset.id.id}', ); - _cacheActivationStart(asset.id); + await _cacheActivationStart(asset.id); ActivationProgress? lastActivationProgress; await for (final activationProgress in _sdk.assets.activateAsset( asset, )) { - _cacheActivationProgress(asset.id, activationProgress); + await _cacheActivationProgress(asset.id, activationProgress); lastActivationProgress = activationProgress; } if (lastActivationProgress?.isSuccess ?? false) { - _cacheActivationComplete(asset.id); + await _cacheActivationComplete(asset.id); return ArrrActivationResultSuccess( Stream.value( ActivationProgress( status: 'Activation completed successfully', + progressPercentage: 100, + isComplete: true, progressDetails: ActivationProgressDetails( currentStep: ActivationStep.complete, stepCount: 1, @@ -161,8 +163,8 @@ class ArrrActivationService { }, maxAttempts: maxAttempts, backoffStrategy: ExponentialBackoff( - initialDelay: const Duration(seconds: 1), - maxDelay: const Duration(seconds: 5), + initialDelay: const Duration(seconds: 5), + maxDelay: const Duration(seconds: 30), ), shouldRetry: (error) => error is _RetryableZhtlcActivationException, onRetry: (currentAttempt, error, delay) { @@ -180,7 +182,7 @@ class ArrrActivationService { e, stackTrace, ); - _cacheActivationError(asset.id, e.toString()); + await _cacheActivationError(asset.id, e.toString()); return ArrrActivationResultError(e.toString()); } } @@ -200,52 +202,75 @@ class ArrrActivationService { /// Activation status caching for UI display final Map _activationCache = {}; + final ReadWriteMutex _activationCacheMutex = ReadWriteMutex(); - void _cacheActivationStart(AssetId assetId) { - _activationCache[assetId] = ArrrActivationStatusInProgress( - assetId: assetId, - startTime: DateTime.now(), - ); + Future _cacheActivationStart(AssetId assetId) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusInProgress( + assetId: assetId, + startTime: DateTime.now(), + ); + }); } - void _cacheActivationProgress(AssetId assetId, ActivationProgress progress) { - final current = _activationCache[assetId]; - if (current is ArrrActivationStatusInProgress) { - _activationCache[assetId] = (current).copyWith( - progressPercentage: progress.progressPercentage?.toInt(), - currentStep: progress.progressDetails?.currentStep, - statusMessage: progress.status, - ); - } + Future _cacheActivationProgress( + AssetId assetId, + ActivationProgress progress, + ) async { + await _activationCacheMutex.protectWrite(() async { + final current = _activationCache[assetId]; + if (current is ArrrActivationStatusInProgress) { + _activationCache[assetId] = current.copyWith( + progressPercentage: progress.progressPercentage?.toInt(), + currentStep: progress.progressDetails?.currentStep, + statusMessage: progress.status, + ); + } + }); } - void _cacheActivationComplete(AssetId assetId) { - _activationCache[assetId] = ArrrActivationStatusCompleted( - assetId: assetId, - completionTime: DateTime.now(), - ); + Future _cacheActivationComplete(AssetId assetId) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusCompleted( + assetId: assetId, + completionTime: DateTime.now(), + ); + }); } - void _cacheActivationError(AssetId assetId, String errorMessage) { - _activationCache[assetId] = ArrrActivationStatusError( - assetId: assetId, - errorMessage: errorMessage, - errorTime: DateTime.now(), - ); + Future _cacheActivationError( + AssetId assetId, + String errorMessage, + ) async { + await _activationCacheMutex.protectWrite(() async { + _activationCache[assetId] = ArrrActivationStatusError( + assetId: assetId, + errorMessage: errorMessage, + errorTime: DateTime.now(), + ); + }); } // Public method for UI to check activation status - ArrrActivationStatus? getActivationStatus(AssetId assetId) { - return _activationCache[assetId]; + Future getActivationStatus(AssetId assetId) async { + return _activationCacheMutex.protectRead( + () async => _activationCache[assetId], + ); } // Public method for UI to get all cached activation statuses - Map get activationStatuses => - _activationCache.unmodifiable(); + Future> get activationStatuses async { + return _activationCacheMutex.protectRead( + () async => + Map.unmodifiable(_activationCache), + ); + } // Clear cached status when no longer needed - void clearActivationStatus(AssetId assetId) { - _activationCache.remove(assetId); + Future clearActivationStatus(AssetId assetId) async { + await _activationCacheMutex.protectWrite( + () async => _activationCache.remove(assetId), + ); } /// Submit configuration for a pending request @@ -355,20 +380,20 @@ class ArrrActivationService { void _startListeningToAuthChanges() { _authSubscription?.cancel(); _authSubscription = _sdk.auth.watchCurrentUser().listen( - _handleAuthStateChange, + (user) => unawaited(_handleAuthStateChange(user)), ); } /// Handle authentication state changes - void _handleAuthStateChange(KdfUser? user) { + Future _handleAuthStateChange(KdfUser? user) async { if (user == null) { // User signed out - cleanup all active operations - _cleanupOnSignOut(); + await _cleanupOnSignOut(); } } /// Clean up all user-specific state when user signs out - void _cleanupOnSignOut() { + Future _cleanupOnSignOut() async { _log.info('User signed out - cleaning up active ZHTLC activations'); // Cancel all pending configuration requests @@ -383,11 +408,14 @@ class ArrrActivationService { _configCompleters.clear(); // Clear activation cache as it's user-specific - final activeAssets = _activationCache.keys.toList(); - for (final assetId in activeAssets) { - _log.info('Clearing activation status for ${assetId.id}'); - } - _activationCache.clear(); + var activeAssets = []; + await _activationCacheMutex.protectWrite(() async { + activeAssets = _activationCache.keys.toList(); + for (final assetId in activeAssets) { + _log.info('Clearing activation status for ${assetId.id}'); + } + _activationCache.clear(); + }); _log.info( 'Cleanup completed - cancelled ${pendingAssets.length} pending configs and cleared ${activeAssets.length} activation statuses', diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index e88313b0ce..e7b67e35e6 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; +import 'package:komodo_defi_types/komodo_defi_types.dart' + show ActivationStep, AssetId; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart' show LocaleKeys; @@ -43,29 +44,31 @@ class _ZhtlcActivationStatusBarState extends State { void _subscribeToAuthChanges() { _authSubscription = context.read().stream.listen((state) { if (state.currentUser == null) { - _handleSignedOut(); + unawaited(_handleSignedOut()); } }); } void _startPeriodicRefresh() { - _refreshStatuses(); + unawaited(_refreshStatuses()); _refreshTimer = Timer.periodic(const Duration(seconds: 1), (_) { - _refreshStatuses(); + unawaited(_refreshStatuses()); }); } - void _refreshStatuses() { - final newStatuses = widget.activationService.activationStatuses; + Future _refreshStatuses() async { + final newStatuses = await widget.activationService.activationStatuses; - if (mounted) { - setState(() { - _cachedStatuses = newStatuses; - }); + if (!mounted) { + return; } + + setState(() { + _cachedStatuses = newStatuses; + }); } - void _handleSignedOut() { + Future _handleSignedOut() async { if (!mounted) { _cachedStatuses = {}; return; @@ -73,7 +76,12 @@ class _ZhtlcActivationStatusBarState extends State { final assetIds = _cachedStatuses.keys.toList(); for (final assetId in assetIds) { - widget.activationService.clearActivationStatus(assetId); + await widget.activationService.clearActivationStatus(assetId); + } + + if (!mounted) { + _cachedStatuses = {}; + return; } setState(() { @@ -124,30 +132,79 @@ class _ZhtlcActivationStatusBarState extends State { color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), - child: Row( + child: Column( children: [ - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, + Row( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: AutoScrollText( - text: LocaleKeys.activatingZhtlcCoins.plural( - coinCount, - args: [coinNames], + const SizedBox(width: 12), + Expanded( + child: AutoScrollText( + text: LocaleKeys.activatingZhtlcCoins.plural(coinCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + fontWeight: FontWeight.w500, + ), + ), ), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).textTheme.bodySmall?.color, - fontWeight: FontWeight.w500, - ), - ), + ], + ), + const SizedBox(height: 8), + Column( + children: activeStatuses.map((entry) { + final status = entry.value; + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: status.when( + completed: (_, __) => const SizedBox.shrink(), + error: (assetId, errorMessage, errorTime) => Row( + children: [ + Icon( + Icons.error_outline, + size: 14, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 12), + Expanded( + child: AutoScrollText( + text: errorMessage, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + inProgress: + ( + assetId, + startTime, + progressPercentage, + currentStep, + statusMessage, + ) { + return _ActivationStatusDetails( + assetId: assetId, + progressPercentage: + progressPercentage?.toDouble() ?? 0, + currentStep: currentStep!, + statusMessage: + statusMessage ?? LocaleKeys.inProgress.tr(), + ); + }, + ), + ); + }).toList(), ), ], ), @@ -156,3 +213,49 @@ class _ZhtlcActivationStatusBarState extends State { ); } } + +class _ActivationStatusDetails extends StatelessWidget { + const _ActivationStatusDetails({ + required this.assetId, + required this.progressPercentage, + required this.currentStep, + required this.statusMessage, + }); + + final AssetId assetId; + final double progressPercentage; + final ActivationStep currentStep; + final String statusMessage; + + @override + Widget build(BuildContext context) { + final statusDetailsText = + '${assetId.id}: $statusMessage ' + '(${progressPercentage.toStringAsFixed(0)}%)'; + + return Padding( + padding: const EdgeInsets.only(left: 24.0), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: AutoScrollText( + text: statusDetailsText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5faf112c72..f1ee1e444e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -889,7 +889,7 @@ packages: source: hosted version: "7.0.1" mutex: - dependency: transitive + dependency: "direct main" description: name: mutex sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" diff --git a/pubspec.yaml b/pubspec.yaml index 5ba342fe11..46cf4d31c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,7 @@ dependencies: cross_file: 0.3.4+2 # flutter.dev video_player: ^2.9.5 # flutter.dev logging: 1.3.0 + mutex: ^3.1.0 integration_test: # SDK (moved from dev_dependencies to ensure Android release build includes plugin) sdk: flutter diff --git a/sdk b/sdk index e6e924c550..1af4278417 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit e6e924c5504b8edd41381ac116b34a4780750e79 +Subproject commit 1af427841768c5350e018a1b3cf4534898da3fde From 0c34a9beffb1774096cde5d048b737c23dca22d2 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 30 Sep 2025 02:14:59 +0200 Subject: [PATCH 15/24] fix(withdraw): add recipient address and memo (if not empty) --- assets/translations/en.json | 1 + lib/generated/codegen_loader.g.dart | 1 + .../transactions/transaction_details.dart | 9 +++-- .../withdraw_form/withdraw_form.dart | 39 +++++++++++++++---- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index cde938009d..8ea2d7b875 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -774,6 +774,7 @@ "zhtlcStartSyncFromLabel": "Start sync from:", "zhtlcEarliestSaplingOption": "Earliest (sapling)", "zhtlcBlockHeightOption": "Block height", + "zhtlcShieldedAddress": "Shielded", "zhtlcDateTimeOption": "Date & Time", "zhtlcSelectDateTimeLabel": "Select date & time", "zhtlcZcashParamsRequired": "Zcash params path is required", diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index de11af15bc..8831f9808d 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -764,6 +764,7 @@ abstract class LocaleKeys { static const zhtlcStartSyncFromLabel = 'zhtlcStartSyncFromLabel'; static const zhtlcEarliestSaplingOption = 'zhtlcEarliestSaplingOption'; static const zhtlcBlockHeightOption = 'zhtlcBlockHeightOption'; + static const zhtlcShieldedAddress = 'zhtlcShieldedAddress'; static const zhtlcDateTimeOption = 'zhtlcDateTimeOption'; static const zhtlcSelectDateTimeLabel = 'zhtlcSelectDateTimeLabel'; static const zhtlcZcashParamsRequired = 'zhtlcZcashParamsRequired'; diff --git a/lib/views/wallet/coin_details/transactions/transaction_details.dart b/lib/views/wallet/coin_details/transactions/transaction_details.dart index 103333a4ac..43ecc5fda2 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_details.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_details.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; -import 'package:komodo_ui/utils.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/common/screen.dart'; @@ -116,13 +115,17 @@ class TransactionDetails extends StatelessWidget { _buildSimpleData( context, title: LocaleKeys.from.tr(), - value: transaction.from.first, + value: transaction.from.isEmpty + ? LocaleKeys.zhtlcShieldedAddress.tr() + : transaction.from.first, isCopied: true, ), _buildSimpleData( context, title: LocaleKeys.to.tr(), - value: transaction.to.first, + value: transaction.to.isEmpty + ? LocaleKeys.zhtlcShieldedAddress.tr() + : transaction.to.first, isCopied: true, ), SizedBox(height: 16), diff --git a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart index 203b4dd382..4cdfd00617 100644 --- a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -16,6 +16,7 @@ import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart' show CopiedTextV2; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart'; @@ -313,16 +314,27 @@ class WithdrawPreviewDetails extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildRow( + _buildTextRow( LocaleKeys.amount.tr(), preview.balanceChanges.netChange.toString(), ), const SizedBox(height: 8), - _buildRow(LocaleKeys.fee.tr(), preview.fee.formatTotal()), - // Add more preview details as needed + _buildTextRow(LocaleKeys.fee.tr(), preview.fee.formatTotal()), + const SizedBox(height: 8), + _buildRow( + LocaleKeys.recipientAddress.tr(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + for (final recipient in preview.to) + CopiedTextV2(copiedValue: recipient, fontSize: 14), + ], + ), + ), if (preview.memo != null) ...[ const SizedBox(height: 8), - _buildRow(LocaleKeys.memo.tr(), preview.memo!), + _buildTextRow(LocaleKeys.memo.tr(), preview.memo!), ], ], ), @@ -330,10 +342,23 @@ class WithdrawPreviewDetails extends StatelessWidget { ); } - Widget _buildRow(String label, String value) { + Widget _buildTextRow(String label, String value) { + return _buildRow( + label, + AutoScrollText(text: value, textAlign: TextAlign.right), + ); + } + + Widget _buildRow(String label, Widget value) { return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text(label), Text(value)], + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + const SizedBox(width: 12), + Expanded( + child: Align(alignment: Alignment.centerRight, child: value), + ), + ], ); } } From 0e0fd640c870b6a79d8c9165c0950c19694304e1 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 30 Sep 2025 04:05:31 +0200 Subject: [PATCH 16/24] fix(zhtlc): filter out active assets before attempting zhtlc activation This logic was mistakenly not copied over from the normal coin activation function --- lib/bloc/coins_bloc/asset_coin_extension.dart | 29 +++++++++++++++++++ lib/bloc/coins_bloc/coins_repo.dart | 3 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 823b9593b7..5e7b3a1766 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -243,4 +243,33 @@ extension CoinListOps on List { (coin) => activeCoinsMap.contains(coin.id), ).unmodifiable().toList(); } + + Future> removeActiveCoins(KomodoDefiSdk sdk) async { + final activeCoins = await sdk.assets.getActivatedAssets(); + final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); + + return where( + (coin) => !activeCoinsMap.contains(coin.id), + ).unmodifiable().toList(); + } +} + +extension AssetListOps on List { + Future> removeInactiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } + + Future> removeActiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => !activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } } diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index cc2bd6d814..56da3f8a08 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -740,7 +740,8 @@ class CoinsRepo { bool notifyListeners = true, bool addToWalletMetadata = true, }) async { - for (final asset in assets) { + final inactiveAssets = await assets.removeActiveAssets(_kdfSdk); + for (final asset in inactiveAssets) { final coin = coins.firstWhere((coin) => coin.id == asset.id); await _activateZhtlcAsset( asset, From b85c88913905d5b2c5d5a7340218a26650ce7e6d Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 30 Sep 2025 21:25:48 +0200 Subject: [PATCH 17/24] fix(wallet): add resilience to delisted coins for wallet coin metadata --- lib/bloc/coins_bloc/coins_repo.dart | 51 +++++++--------- lib/model/kdf_auth_metadata_extension.dart | 69 ++++++++++++++++------ 2 files changed, 72 insertions(+), 48 deletions(-) diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 56da3f8a08..51fd0119ff 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -26,7 +26,6 @@ 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'; import 'package:web_dex/model/text_error.dart'; -import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; import 'package:web_dex/services/arrr_activation/arrr_activation_service.dart'; @@ -182,29 +181,8 @@ class CoinsRepo { 'Wallet [KdfUser].wallet extension instead.', ) Future> getWalletCoins() async { - final currentUser = await _kdfSdk.auth.currentUser; - if (currentUser == null) { - return []; - } - - return currentUser.wallet.config.activatedCoins - .map((coinId) { - final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); - if (assets.isEmpty) { - _log.warning('No assets found for coinId: $coinId'); - return null; - } - if (assets.length > 1) { - _log.shout( - 'Multiple assets found for coinId: $coinId (${assets.length} assets). ' - 'Selecting the first asset: ${assets.first.id.id}', - ); - } - return assets.single; - }) - .whereType() - .map(_assetToCoinWithoutAddress) - .toList(); + final walletAssets = await _kdfSdk.getWalletAssets(); + return walletAssets.map(_assetToCoinWithoutAddress).toList(); } Coin _assetToCoinWithoutAddress(Asset asset) { @@ -550,7 +528,23 @@ class CoinsRepo { 'select from the available options.', ) Future getFirstPubkey(String coinId) async { - final asset = _kdfSdk.assets.findAssetsByConfigId(coinId).single; + final assets = _kdfSdk.assets.findAssetsByConfigId(coinId); + if (assets.isEmpty) { + _log.warning( + 'Unable to fetch pubkey for coinId $coinId because the asset is no longer available.', + ); + return null; + } + + if (assets.length > 1) { + final assetIds = assets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for coinId $coinId while fetching pubkey: $assetIds'; + _log.shout(message); + throw StateError(message); + } + + final asset = assets.single; final pubkeys = await _kdfSdk.pubkeys.getPubkeys(asset); if (pubkeys.keys.isEmpty) { return null; @@ -583,12 +577,7 @@ class CoinsRepo { // will hit rate limits and have reduced market metrics functionality. // This will happen regardless of chunk size. The rate limits are per IP // per hour. - final coinIds = await _kdfSdk.getWalletCoinIds(); - final activatedAssets = coinIds - .map((coinId) => _kdfSdk.assets.findAssetsByConfigId(coinId)) - .where((assets) => assets.isNotEmpty) - .map((assets) => assets.single) - .toList(); + final activatedAssets = await _kdfSdk.getWalletAssets(); final Iterable targetAssets = activatedAssets.isNotEmpty ? activatedAssets : _kdfSdk.assets.available.values; diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart index 202cab7e84..27dfa875ad 100644 --- a/lib/model/kdf_auth_metadata_extension.dart +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -1,10 +1,13 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart' show Asset; +import 'package:logging/logging.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; +final Logger _walletMetadataLog = Logger('KdfAuthMetadataExtension'); + extension KdfAuthMetadataExtension on KomodoDefiSdk { /// Checks if a wallet with the specified ID exists in the system. /// @@ -31,33 +34,65 @@ extension KdfAuthMetadataExtension on KomodoDefiSdk { return user?.metadata.valueOrNull>('activated_coins') ?? []; } + /// Returns the stored list of wallet assets resolved from configuration IDs. + /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// + /// Throws [StateError] if multiple assets are found with the same configuration ID. + Future> getWalletAssets() async { + final coinIds = await getWalletCoinIds(); + if (coinIds.isEmpty) { + return []; + } + + final missingCoinIds = {}; + final walletAssets = []; + + for (final coinId in coinIds) { + final matchingAssets = assets.findAssetsByConfigId(coinId); + if (matchingAssets.isEmpty) { + missingCoinIds.add(coinId); + continue; + } + + if (matchingAssets.length > 1) { + final assetIds = matchingAssets.map((asset) => asset.id.id).join(', '); + final message = + 'Multiple assets found for activated coin ID "$coinId": $assetIds'; + _walletMetadataLog.shout(message); + throw StateError(message); + } + + walletAssets.add(matchingAssets.single); + } + + if (missingCoinIds.isNotEmpty) { + _walletMetadataLog.warning( + 'Skipping ${missingCoinIds.length} activated coin(s) that are no longer ' + 'available in the SDK (likely delisted): ' + '${missingCoinIds.join(', ')}', + ); + } + + return walletAssets; + } + /// Returns the stored list of wallet coins converted from asset configuration IDs. /// /// This method retrieves the coin IDs from user metadata and converts them /// to [Coin] objects. Uses `single` to maintain existing behavior which will /// throw an exception if multiple assets share the same ticker. /// + /// Missing assets (for example, delisted coins) are skipped and logged for + /// visibility. + /// /// If no user is signed in, returns an empty list. /// /// Throws [StateError] if multiple assets are found with the same configuration ID. Future> getWalletCoins() async { - final assets = await getWalletAssets(); - return assets - // use single to stick to the existing behaviour around assetByTicker - // which will cause the application to crash if there are - // multiple assets with the same ticker - .map((asset) => asset.toCoin()) - .toList(); - } - - Future> getWalletAssets() async { - final coinIds = await getWalletCoinIds(); - return coinIds - // use single to stick to the existing behaviour around assetByTicker - // which will cause the application to crash if there are - // multiple assets with the same ticker - .map((coinId) => assets.findAssetsByConfigId(coinId).single) - .toList(); + final walletAssets = await getWalletAssets(); + return walletAssets.map((asset) => asset.toCoin()).toList(); } /// Adds new coin/asset IDs to the current user's activated coins list. From a130697f7546ffec2a270d8334cccfb11860b4b5 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 30 Sep 2025 21:27:38 +0200 Subject: [PATCH 18/24] fix(version-info): ensure that apiversion and commit has persist --- lib/bloc/version_info/version_info_bloc.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/bloc/version_info/version_info_bloc.dart b/lib/bloc/version_info/version_info_bloc.dart index d8c0e94f5f..62abe28808 100644 --- a/lib/bloc/version_info/version_info_bloc.dart +++ b/lib/bloc/version_info/version_info_bloc.dart @@ -48,11 +48,11 @@ class VersionInfoBloc extends Bloc { 'Commit: $commitHash', ); - final basicInfo = VersionInfoLoaded( + var currentInfo = VersionInfoLoaded( appVersion: appVersion, commitHash: commitHash, ); - emit(basicInfo); + emit(currentInfo); try { final apiVersion = await _mm2Api.version(); @@ -63,7 +63,8 @@ class VersionInfoBloc extends Bloc { final apiCommitHash = apiVersion != null ? () => _tryParseCommitHash(apiVersion) : null; - emit(basicInfo.copyWith(apiCommitHash: apiCommitHash)); + currentInfo = currentInfo.copyWith(apiCommitHash: apiCommitHash); + emit(currentInfo); _logger.info( 'MM2 API version loaded successfully - Version: $apiVersion, ' 'Commit: ${apiCommitHash?.call()}', @@ -83,12 +84,11 @@ class VersionInfoBloc extends Bloc { ); } - emit( - basicInfo.copyWith( - currentCoinsCommit: () => _tryParseCommitHash(currentCommit ?? '-'), - latestCoinsCommit: () => _tryParseCommitHash(latestCommit ?? '-'), - ), + currentInfo = currentInfo.copyWith( + currentCoinsCommit: () => _tryParseCommitHash(currentCommit ?? '-'), + latestCoinsCommit: () => _tryParseCommitHash(latestCommit ?? '-'), ); + emit(currentInfo); _logger.info( 'SDK coins commits loaded successfully - Current: $currentCommit, ' 'Latest: $latestCommit', From 35372312751d5194f188b5bd066d2aa73ec8a3e0 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 1 Oct 2025 02:31:20 +0200 Subject: [PATCH 19/24] perf(market-data): add update backoff strategy and guard against fetching tx history of unactivated assets (#3162) * fix(wallet): add resilience to delisted coins for wallet coin metadata * fix(version-info): ensure that apiversion and commit has persist * perf(market-metrics): add backoff strategy for periodic updates TODO: ensure that the portfolio growth event is only fired once to avoid restarting the periodic updates on every user input (likely the case currently) * refactor(market-metrics): extract common coin filtering for test coin removal * refactor(market-metrics): extract calculation functions into extension * perf(coins-bloc): add initialisation status tracking for startup * refactor: add loop exits, update comments, remove unused event member --- .../bloc/asset_overview_bloc.dart | 13 +- .../update_frequency_backoff_strategy.dart | 76 ++++++ .../portfolio_growth_bloc.dart | 233 ++++++++---------- .../portfolio_growth_event.dart | 14 +- .../profit_loss/profit_loss_bloc.dart | 109 +++++--- .../profit_loss/profit_loss_event.dart | 4 +- lib/bloc/coins_bloc/asset_coin_extension.dart | 98 ++++++-- lib/bloc/coins_bloc/coins_bloc.dart | 96 +++++++- lib/bloc/coins_bloc/coins_repo.dart | 12 +- ...ncy_backoff_strategy_integration_test.dart | 136 ++++++++++ ...pdate_frequency_backoff_strategy_test.dart | 164 ++++++++++++ 11 files changed, 741 insertions(+), 214 deletions(-) create mode 100644 lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart create mode 100644 test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart create mode 100644 test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart diff --git a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart index 2332171718..48f942c1cc 100644 --- a/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -94,10 +94,17 @@ class AssetOverviewBloc extends Bloc { return; } - await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + final supportedCoins = await event.coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load portfolio overview for'); + return; + } + + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); - final activeCoins = await event.coins.removeInactiveCoins(_sdk); + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isEmpty) { + _log.warning('No active coins to load portfolio overview for'); return; } @@ -136,7 +143,7 @@ class AssetOverviewBloc extends Bloc { emit( PortfolioAssetsOverviewLoadSuccess( - selectedAssetIds: event.coins.map((coin) => coin.id.id).toList(), + selectedAssetIds: activeCoins.map((coin) => coin.id.id).toList(), assetPortionPercentages: assetPortionPercentages, totalInvestment: totalInvestment, totalValue: FiatValue.usd(profitAmount), diff --git a/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart new file mode 100644 index 0000000000..c345e38455 --- /dev/null +++ b/lib/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; + +/// A strategy for implementing exponential backoff with paired intervals. +/// The pattern is: 1min, 1min, 2min, 2min, 4min, 4min, 8min, 8min, etc. +/// This reduces API calls while still providing reasonable update frequency. +class UpdateFrequencyBackoffStrategy { + UpdateFrequencyBackoffStrategy({ + this.baseInterval = const Duration(minutes: 1), + this.maxInterval = const Duration(hours: 1), + }); + + /// The base interval for the first attempts (default: 2 minutes) + final Duration baseInterval; + + /// The maximum interval to backoff to (default: 1 hour) + final Duration maxInterval; + + int _attemptCount = 0; + + /// Reset the backoff strategy to start from the beginning + void reset() { + _attemptCount = 0; + } + + /// Get the current attempt count + int get attemptCount => _attemptCount; + + /// Get the next interval duration and increment the attempt count + Duration getNextInterval() { + final interval = getCurrentInterval(); + _attemptCount++; + return interval; + } + + /// Get the current interval duration without incrementing the attempt count + Duration getCurrentInterval() { + // Calculate which "pair" we're in (0, 1, 2, 3, ...) + // Each pair has 2 attempts with the same interval + final pairIndex = _attemptCount ~/ 2; + + // Calculate the multiplier: 2^pairIndex + final multiplier = math.pow(2, pairIndex).toInt(); + + // Calculate the interval + final intervalMs = baseInterval.inMilliseconds * multiplier; + + // Cap at maximum interval + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + + return Duration(milliseconds: cappedIntervalMs); + } + + /// Check if we should update the cache on the current attempt + /// Returns true for cache update attempts, false for cache-only reads + bool shouldUpdateCache() { + // Update cache on every attempt for now, but this could be modified + // to only update on certain intervals if needed + return true; + } + + /// Get a preview of the next N intervals without affecting the state + List previewNextIntervals(int count) { + final currentAttempt = _attemptCount; + final intervals = []; + + for (int i = 0; i < count; i++) { + final pairIndex = (currentAttempt + i) ~/ 2; + final multiplier = math.pow(2, pairIndex).toInt(); + final intervalMs = baseInterval.inMilliseconds * multiplier; + final cappedIntervalMs = math.min(intervalMs, maxInterval.inMilliseconds); + intervals.add(Duration(milliseconds: cappedIntervalMs)); + } + + return intervals; + } +} \ No newline at end of file diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index e16efd10b6..e9f3747cf2 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -2,19 +2,17 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:decimal/decimal.dart'; import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; -import 'package:rational/rational.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; -import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -24,8 +22,10 @@ class PortfolioGrowthBloc PortfolioGrowthBloc({ required PortfolioGrowthRepository portfolioGrowthRepository, required KomodoDefiSdk sdk, + UpdateFrequencyBackoffStrategy? backoffStrategy, }) : _sdk = sdk, _portfolioGrowthRepository = portfolioGrowthRepository, + _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), super(const PortfolioGrowthInitial()) { // Use the restartable transformer for period change events to avoid // overlapping events if the user rapidly changes the period (i.e. faster @@ -44,6 +44,7 @@ class PortfolioGrowthBloc final PortfolioGrowthRepository _portfolioGrowthRepository; final KomodoDefiSdk _sdk; final _log = Logger('PortfolioGrowthBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioGrowth( PortfolioGrowthClearRequested event, @@ -56,12 +57,13 @@ class PortfolioGrowthBloc PortfolioGrowthPeriodChanged event, Emitter emit, ) { + final coins = event.coins.withoutTestCoins(); final ( int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, ) = _calculateCoinProgressCounters( - event.coins, + coins, ); final currentState = state; if (currentState is PortfolioGrowthChartLoadSuccess) { @@ -104,10 +106,9 @@ class PortfolioGrowthBloc add( PortfolioGrowthLoadRequested( - coins: event.coins, + coins: coins, selectedPeriod: event.selectedPeriod, fiatCoinId: 'USDT', - updateFrequency: event.updateFrequency, walletId: event.walletId, ), ); @@ -118,16 +119,22 @@ class PortfolioGrowthBloc Emitter emit, ) async { try { - final List coins = await _removeUnsupportedCoins(event); + final List coins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. - if (coins.isEmpty && event.coins.length <= 1) { + final filteredEventCoins = event.coins.withoutTestCoins(); + if (coins.isEmpty && filteredEventCoins.length <= 1) { final ( int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, ) = _calculateCoinProgressCounters( - event.coins, + filteredEventCoins, ); return emit( PortfolioGrowthChartUnsupported( @@ -139,9 +146,8 @@ class PortfolioGrowthBloc ); } - final initialActiveCoins = await coins.removeInactiveCoins(_sdk); await _loadChart( - initialActiveCoins, + filteredEventCoins, event, useCache: true, ).then(emit.call).catchError((Object error, StackTrace stackTrace) { @@ -154,79 +160,31 @@ class PortfolioGrowthBloc // In case most coins are activating on wallet startup, wait for at least // 50% of the coins to be enabled before attempting to load the uncached // chart. - await _sdk.waitForEnabledCoinsToPassThreshold(event.coins); + await _sdk.waitForEnabledCoinsToPassThreshold(filteredEventCoins); // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. - final activeCoins = await coins.removeInactiveCoins(_sdk); - if (activeCoins.isNotEmpty) { - await _loadChart( - activeCoins, - event, - useCache: false, - ).then(emit.call).catchError((Object error, StackTrace stackTrace) { - _log.shout('Failed to load chart', error, stackTrace); - // Don't emit an error state here. If cached and uncached attempts - // both fail, the periodic refresh attempts should recovery - // at the cost of a longer first loading time. - }); - } + await _loadChart( + filteredEventCoins, + event, + useCache: false, + ).then(emit.call).catchError((Object error, StackTrace stackTrace) { + _log.shout('Failed to load chart', error, stackTrace); + // Don't emit an error state here. If cached and uncached attempts + // both fail, the periodic refresh attempts should recovery + // at the cost of a longer first loading time. + }); } catch (error, stackTrace) { _log.shout('Failed to load portfolio growth', error, stackTrace); // Don't emit an error state here, as the periodic refresh attempts should // recover at the cost of a longer first loading time. } - final periodicUpdate = Stream.periodic( - event.updateFrequency, - ).asyncMap((_) async => _fetchPortfolioGrowthChart(event)); - - // Use await for here to allow for the async update handler. The previous - // implementation awaited the emit.forEach to ensure that cancelling the - // event handler with transformers would stop the previous periodic updates. - await for (final data in periodicUpdate) { - try { - emit( - await _handlePortfolioGrowthUpdate( - data, - event.selectedPeriod, - event.coins, - ), - ); - } catch (error, stackTrace) { - _log.shout('Failed to load portfolio growth', error, stackTrace); - final ( - int totalCoins, - int coinsWithKnownBalance, - int coinsWithKnownBalanceAndFiat, - ) = _calculateCoinProgressCounters( - event.coins, - ); - emit( - GrowthChartLoadFailure( - error: TextError(error: 'Failed to load portfolio growth'), - selectedPeriod: event.selectedPeriod, - totalCoins: totalCoins, - coinsWithKnownBalance: coinsWithKnownBalance, - coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, - ), - ); - } - } - } + // Reset backoff strategy for new load request + _backoffStrategy.reset(); - Future> _removeUnsupportedCoins( - PortfolioGrowthLoadRequested event, - ) async { - final List coins = List.from(event.coins); - for (final coin in event.coins) { - final isCoinSupported = await _portfolioGrowthRepository - .isCoinChartSupported(coin.id, event.fiatCoinId); - if (!isCoinSupported) { - coins.remove(coin); - } - } - return coins; + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _loadChart( @@ -234,8 +192,9 @@ class PortfolioGrowthBloc PortfolioGrowthLoadRequested event, { required bool useCache, }) async { + final activeCoins = await coins.removeInactiveCoins(_sdk); final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( - coins, + activeCoins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: useCache, @@ -245,16 +204,16 @@ class PortfolioGrowthBloc return state; } - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = await _calculateTotalChange24h(coins); - final percentageChange24h = await _calculatePercentageChange24h(coins); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); final ( int totalCoins, int coinsWithKnownBalance, int coinsWithKnownBalanceAndFiat, ) = _calculateCoinProgressCounters( - event.coins, + coins, ); return PortfolioGrowthChartLoadSuccess( @@ -271,22 +230,28 @@ class PortfolioGrowthBloc ); } - Future _fetchPortfolioGrowthChart( + Future<(ChartData, List)> _fetchPortfolioGrowthChart( PortfolioGrowthLoadRequested event, ) async { // Do not let transaction loading exceptions stop the periodic updates try { - final supportedCoins = await _removeUnsupportedCoins(event); + final supportedCoins = await event.coins.filterSupportedCoins( + (coin) => _portfolioGrowthRepository.isCoinChartSupported( + coin.id, + event.fiatCoinId, + ), + ); final coins = await supportedCoins.removeInactiveCoins(_sdk); - return await _portfolioGrowthRepository.getPortfolioGrowthChart( + final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( coins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: false, ); + return (chart, coins); } catch (error, stackTrace) { _log.shout('Empty growth chart on periodic update', error, stackTrace); - return ChartData.empty(); + return (ChartData.empty(), []); } } @@ -300,9 +265,9 @@ class PortfolioGrowthBloc } final percentageIncrease = growthChart.percentageIncrease; - final totalBalance = _calculateTotalBalance(coins); - final totalChange24h = await _calculateTotalChange24h(coins); - final percentageChange24h = await _calculatePercentageChange24h(coins); + final totalBalance = coins.totalLastKnownUsdBalance(_sdk); + final totalChange24h = await coins.totalChange24h(_sdk); + final percentageChange24h = await coins.percentageChange24h(_sdk); final ( int totalCoins, @@ -326,52 +291,6 @@ class PortfolioGrowthBloc ); } - /// Calculate the total balance of all coins in USD - double _calculateTotalBalance(List coins) { - double total = coins.fold( - 0, - (prev, coin) => prev + (coin.lastKnownUsdBalance(_sdk) ?? 0), - ); - - // Return at least 0.01 if total is positive but very small - if (total > 0 && total < 0.01) { - return 0.01; - } - - return total; - } - - /// Calculate the total 24h change in USD value - /// TODO: look into avoiding zero default values here if no data is available - Future _calculateTotalChange24h(List coins) async { - Rational totalChange = Rational.zero; - for (final coin in coins) { - final double usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0; - final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); - final change24h = - await _sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; - totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); - } - return totalChange; - } - - /// Calculate the percentage change over 24h for the entire portfolio - Future _calculatePercentageChange24h(List coins) async { - final double totalBalance = _calculateTotalBalance(coins); - final Rational totalBalanceRational = Rational.parse( - totalBalance.toString(), - ); - final Rational totalChange = await _calculateTotalChange24h(coins); - - // Avoid division by zero or very small balances - if (totalBalanceRational <= Rational.fromInt(1, 100)) { - return Rational.zero; - } - - // Return the percentage change - return (totalChange / totalBalanceRational) * Rational.fromInt(100); - } - /// Calculate progress counters for balances and fiat prices /// - totalCoins: total coins being considered (input list length) /// - coinsWithKnownBalance: number of coins with a known last balance @@ -392,4 +311,54 @@ class PortfolioGrowthBloc } return (totalCoins, withBalance, withBalanceAndFiat); } + + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + PortfolioGrowthLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping portfolio growth periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); + + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping portfolio growth periodic update: bloc closed during delay.', + ); + break; + } + + final (chart, coins) = await _fetchPortfolioGrowthChart(event); + emit( + await _handlePortfolioGrowthUpdate( + chart, + event.selectedPeriod, + coins, + ), + ); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio growth', error, stackTrace); + final ( + int totalCoins, + int coinsWithKnownBalance, + int coinsWithKnownBalanceAndFiat, + ) = _calculateCoinProgressCounters( + event.coins.withoutTestCoins(), + ); + emit( + GrowthChartLoadFailure( + error: TextError(error: 'Failed to load portfolio growth'), + selectedPeriod: event.selectedPeriod, + totalCoins: totalCoins, + coinsWithKnownBalance: coinsWithKnownBalance, + coinsWithKnownBalanceAndFiat: coinsWithKnownBalanceAndFiat, + ), + ); + } + } + } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart index 0aa750b24a..957d1e97fb 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart @@ -17,23 +17,15 @@ class PortfolioGrowthLoadRequested extends PortfolioGrowthEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final List coins; final String fiatCoinId; final Duration selectedPeriod; final String walletId; - final Duration updateFrequency; @override - List get props => [ - coins, - fiatCoinId, - selectedPeriod, - walletId, - updateFrequency, - ]; + List get props => [coins, fiatCoinId, selectedPeriod, walletId]; } class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { @@ -41,14 +33,12 @@ class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { required this.selectedPeriod, required this.coins, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), }); final Duration selectedPeriod; final List coins; final String walletId; - final Duration updateFrequency; @override - List get props => [selectedPeriod, coins, walletId, updateFrequency]; + List get props => [selectedPeriod, coins, walletId]; } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart index ad14866c6c..b382058728 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart @@ -7,6 +7,7 @@ import 'package:equatable/equatable.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; @@ -18,8 +19,12 @@ part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; class ProfitLossBloc extends Bloc { - ProfitLossBloc(this._profitLossRepository, this._sdk) - : super(const ProfitLossInitial()) { + ProfitLossBloc( + this._profitLossRepository, + this._sdk, { + UpdateFrequencyBackoffStrategy? backoffStrategy, + }) : _backoffStrategy = backoffStrategy ?? UpdateFrequencyBackoffStrategy(), + super(const ProfitLossInitial()) { // Use the restartable transformer for load events to avoid overlapping // events if the user rapidly changes the period (i.e. faster than the // previous event can complete). @@ -35,6 +40,7 @@ class ProfitLossBloc extends Bloc { final KomodoDefiSdk _sdk; final _log = Logger('ProfitLossBloc'); + final UpdateFrequencyBackoffStrategy _backoffStrategy; void _onClearPortfolioProfitLoss( ProfitLossPortfolioChartClearRequested event, @@ -48,14 +54,12 @@ class ProfitLossBloc extends Bloc { Emitter emit, ) async { try { - final supportedCoins = await _removeUnsupportedCons( - event.coins, - event.fiatCoinId, - ); + final supportedCoins = await event.coins.filterSupportedCoins(); + final filteredEventCoins = event.coins.withoutTestCoins(); final initialActiveCoins = await supportedCoins.removeInactiveCoins(_sdk); // Charts for individual coins (coin details) are parsed here as well, // and should be hidden if not supported. - if (supportedCoins.isEmpty && event.coins.length <= 1) { + if (supportedCoins.isEmpty && filteredEventCoins.length <= 1) { return emit( PortfolioProfitLossChartUnsupported( selectedPeriod: event.selectedPeriod, @@ -75,7 +79,9 @@ class ProfitLossBloc extends Bloc { }); // Fetch the un-cached version of the chart to update the cache. - await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + if (supportedCoins.isNotEmpty) { + await _sdk.waitForEnabledCoinsToPassThreshold(supportedCoins); + } final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); if (activeCoins.isNotEmpty) { await _getProfitLossChart( @@ -96,19 +102,11 @@ class ProfitLossBloc extends Bloc { // recover at the cost of a longer first loading time. } - await emit.forEach( - Stream.periodic(event.updateFrequency).asyncMap( - (_) async => _getProfitLossChart(event, event.coins, useCache: false), - ), - onData: (ProfitLossState updatedChartState) => updatedChartState, - onError: (e, s) { - _log.shout('Failed to load portfolio profit/loss', e, s); - return ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss'), - selectedPeriod: event.selectedPeriod, - ); - }, - ); + // Reset backoff strategy for new load request + _backoffStrategy.reset(); + + // Create periodic update stream with dynamic intervals + await _runPeriodicUpdates(event, emit); } Future _getProfitLossChart( @@ -121,6 +119,7 @@ class ProfitLossBloc extends Bloc { try { final filteredChart = await _getSortedProfitLossChartForCoins( event, + coins, useCache: useCache, ); final unCachedProfitIncrease = filteredChart.increase; @@ -141,19 +140,6 @@ class ProfitLossBloc extends Bloc { } } - Future> _removeUnsupportedCons( - List walletCoins, - String fiatCoinId, - ) async { - final coins = List.of(walletCoins); - for (final coin in coins) { - if (coin.isTestCoin) { - coins.remove(coin); - } - } - return coins; - } - Future _onPortfolioPeriodChanged( ProfitLossPortfolioPeriodChanged event, Emitter emit, @@ -191,7 +177,8 @@ class ProfitLossBloc extends Bloc { } Future _getSortedProfitLossChartForCoins( - ProfitLossPortfolioChartLoadRequested event, { + ProfitLossPortfolioChartLoadRequested event, + List coins, { bool useCache = true, }) async { if (!await _sdk.auth.isSignedIn()) { @@ -199,8 +186,18 @@ class ProfitLossBloc extends Bloc { return ChartData.empty(); } + final supportedCoins = await coins.filterSupportedCoins(); + if (supportedCoins.isEmpty) { + _log.warning('No supported coins to load profit/loss chart for'); + return ChartData.empty(); + } + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + if (activeCoins.isEmpty) { + _log.warning('No active coins to load profit/loss chart for'); + return ChartData.empty(); + } final chartsList = await Future.wait( - event.coins.map((coin) async { + activeCoins.map((coin) async { // Catch any errors and return an empty chart to prevent a single coin // from breaking the entire portfolio chart. try { @@ -234,4 +231,44 @@ class ProfitLossBloc extends Bloc { chartsList.removeWhere((element) => element.isEmpty); return Charts.merge(chartsList)..sort((a, b) => a.x.compareTo(b.x)); } + + /// Run periodic updates with exponential backoff strategy + Future _runPeriodicUpdates( + ProfitLossPortfolioChartLoadRequested event, + Emitter emit, + ) async { + while (true) { + if (isClosed || emit.isDone) { + _log.fine('Stopping profit/loss periodic updates: bloc closed.'); + break; + } + try { + await Future.delayed(_backoffStrategy.getNextInterval()); + + if (isClosed || emit.isDone) { + _log.fine( + 'Skipping profit/loss periodic update: bloc closed during delay.', + ); + break; + } + + final supportedCoins = await event.coins.filterSupportedCoins(); + final activeCoins = await supportedCoins.removeInactiveCoins(_sdk); + final updatedChartState = await _getProfitLossChart( + event, + activeCoins, + useCache: false, + ); + emit(updatedChartState); + } catch (error, stackTrace) { + _log.shout('Failed to load portfolio profit/loss', error, stackTrace); + emit( + ProfitLossLoadFailure( + error: TextError(error: 'Failed to load portfolio profit/loss'), + selectedPeriod: event.selectedPeriod, + ), + ); + } + } + } } diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart index 14ff2ff6da..15049b2f8e 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart @@ -17,7 +17,7 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 1), + this.updateFrequency = const Duration(minutes: 2), }); final List coins; @@ -39,7 +39,7 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { class ProfitLossPortfolioPeriodChanged extends ProfitLossEvent { const ProfitLossPortfolioPeriodChanged({ required this.selectedPeriod, - this.updateFrequency = const Duration(minutes: 1), + this.updateFrequency = const Duration(minutes: 2), }); final Duration selectedPeriod; diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 5e7b3a1766..f1eef13e7c 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -2,10 +2,12 @@ import 'package:decimal/decimal.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:rational/rational.dart' show Rational; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; import 'package:web_dex/shared/utils/extensions/collection_extensions.dart'; +import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; extension AssetCoinExtension on Asset { Coin toCoin() { @@ -234,7 +236,51 @@ extension AssetBalanceExtension on Coin { } } -extension CoinListOps on List { +extension AssetListOps on List { + Future> removeInactiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } + + Future> removeActiveAssets(KomodoDefiSdk sdk) async { + final activeAssets = await sdk.assets.getActivatedAssets(); + final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + + return where( + (asset) => !activeAssetsMap.contains(asset.id), + ).unmodifiable().toList(); + } +} + +extension CoinSupportOps on Iterable { + /// Returns a list excluding test coins. Useful when filtering coins before + /// running portfolio calculations that assume production assets only. + List withoutTestCoins() => + where((coin) => !coin.isTestCoin).unmodifiable().toList(); + + /// Filters out unsupported coins by first removing test coins and then + /// evaluating the optional [isSupported] predicate. When the predicate is not + /// provided, only test coins are removed. + Future> filterSupportedCoins([ + Future Function(Coin coin)? isSupported, + ]) async { + final predicate = isSupported ?? _alwaysSupported; + final supportedCoins = []; + for (final coin in this) { + if (coin.isTestCoin) continue; + if (await predicate(coin)) { + supportedCoins.add(coin); + } + } + return supportedCoins.unmodifiable().toList(); + } + + static Future _alwaysSupported(Coin _) async => true; + Future> removeInactiveCoins(KomodoDefiSdk sdk) async { final activeCoins = await sdk.assets.getActivatedAssets(); final activeCoinsMap = activeCoins.map((e) => e.id).toSet(); @@ -252,24 +298,46 @@ extension CoinListOps on List { (coin) => !activeCoinsMap.contains(coin.id), ).unmodifiable().toList(); } -} -extension AssetListOps on List { - Future> removeInactiveAssets(KomodoDefiSdk sdk) async { - final activeAssets = await sdk.assets.getActivatedAssets(); - final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + double totalLastKnownUsdBalance(KomodoDefiSdk sdk) { + double total = fold( + 0.00, + (prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0), + ); - return where( - (asset) => activeAssetsMap.contains(asset.id), - ).unmodifiable().toList(); + // Return at least 0.01 if total is positive but very small + if (total > 0 && total < 0.01) { + return 0.01; + } + + return total; } - Future> removeActiveAssets(KomodoDefiSdk sdk) async { - final activeAssets = await sdk.assets.getActivatedAssets(); - final activeAssetsMap = activeAssets.map((e) => e.id).toSet(); + Future totalChange24h(KomodoDefiSdk sdk) async { + Rational totalChange = Rational.zero; + for (final coin in this) { + final double usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0; + final usdBalanceDecimal = Decimal.parse(usdBalance.toString()); + final change24h = + await sdk.marketData.priceChange24h(coin.id) ?? Decimal.zero; + totalChange += change24h * usdBalanceDecimal / Decimal.fromInt(100); + } + return totalChange; + } - return where( - (asset) => !activeAssetsMap.contains(asset.id), - ).unmodifiable().toList(); + Future percentageChange24h(KomodoDefiSdk sdk) async { + final double totalBalance = totalLastKnownUsdBalance(sdk); + final Rational totalBalanceRational = Rational.parse( + totalBalance.toString(), + ); + final Rational totalChange = await totalChange24h(sdk); + + // Avoid division by zero or very small balances + if (totalBalanceRational <= Rational.fromInt(1, 100)) { + return Rational.zero; + } + + // Return the percentage change + return (totalChange / totalBalanceRational) * Rational.fromInt(100); } } diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 3784a0a6e0..2dd8b73a58 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -8,6 +8,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; 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/cex_market_data/sdk_auth_activation_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; @@ -45,6 +46,7 @@ class CoinsBloc extends Bloc { StreamSubscription? _enabledCoinsSubscription; Timer? _updateBalancesTimer; Timer? _updatePricesTimer; + bool _isInitialActivationInProgress = false; @override Future close() async { @@ -60,11 +62,24 @@ class CoinsBloc extends Bloc { Emitter emit, ) async { try { - // Return early if the coin is not yet in wallet coins, meaning that - // it's not yet activated. - // TODO: update this once coin activation is fully handled by the SDK + if (_isInitialActivationInProgress) { + _log.info( + 'Skipping pubkeys request for ${event.coinId} while initial activation is in progress.', + ); + return; + } + + // Coins are added to walletCoins before activation even starts + // to show them in the UI regardless of activation state. + // If the coin is not found here, it means the auth state handler + // has not pre-populated the list with activating coins yet. final coin = state.walletCoins[event.coinId]; - if (coin == null) return; + if (coin == null) { + _log.warning( + 'Coin ${event.coinId} not found in wallet coins, cannot fetch pubkeys', + ); + return; + } // Get pubkeys from the SDK through the repo final asset = _kdfSdk.assets.available[coin.id]!; @@ -287,6 +302,7 @@ class CoinsBloc extends Bloc { CoinsSessionStarted event, Emitter emit, ) async { + _isInitialActivationInProgress = true; try { // Ensure any cached addresses/pubkeys from a previous wallet are cleared // so that UI fetches fresh pubkeys for the newly logged-in wallet. @@ -298,11 +314,20 @@ class CoinsBloc extends Bloc { // 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); - - add(CoinsBalancesRefreshed()); - add(CoinsBalanceMonitoringStarted()); + _scheduleInitialBalanceRefresh(coinsToActivate); + + final activationFuture = _activateCoins(coinsToActivate, emit); + unawaited(() async { + try { + await activationFuture; + } catch (e, s) { + _log.shout('Error during initial coin activation', e, s); + } finally { + _isInitialActivationInProgress = false; + } + }()); } catch (e, s) { + _isInitialActivationInProgress = false; _log.shout('Error on login', e, s); } } @@ -311,6 +336,7 @@ class CoinsBloc extends Bloc { CoinsSessionEnded event, Emitter emit, ) async { + _resetInitialActivationState(); add(CoinsBalanceMonitoringStopped()); emit( @@ -324,6 +350,60 @@ class CoinsBloc extends Bloc { _coinsRepo.flushCache(); } + void _scheduleInitialBalanceRefresh(Iterable coinsToActivate) { + if (isClosed) return; + + final knownCoins = _coinsRepo.getKnownCoinsMap(); + final walletCoinsForThreshold = coinsToActivate + .map((coinId) => knownCoins[coinId]) + .whereType() + .toList(); + + if (walletCoinsForThreshold.isEmpty) { + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + return; + } + + unawaited(() async { + var triggeredByThreshold = false; + try { + triggeredByThreshold = await _kdfSdk.waitForEnabledCoinsToPassThreshold( + walletCoinsForThreshold, + threshold: 0.8, + timeout: const Duration(minutes: 1), + ); + } catch (e, s) { + _log.shout( + 'Failed while waiting for enabled coins threshold during login', + e, + s, + ); + } + + if (isClosed) { + return; + } + + if (triggeredByThreshold) { + _log.fine( + 'Initial balance refresh triggered after 80% of coins activated.', + ); + } else { + _log.fine( + 'Initial balance refresh triggered after timeout while waiting for coin activation.', + ); + } + + add(CoinsBalancesRefreshed()); + add(CoinsBalanceMonitoringStarted()); + }()); + } + + void _resetInitialActivationState() { + _isInitialActivationInProgress = false; + } + Future _activateCoins( Iterable coins, Emitter emit, diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 51fd0119ff..c961cdea52 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -230,14 +230,14 @@ class CoinsRepo { /// exponential backoff for up to the specified duration. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [assets]: List of assets to activate /// - [notifyListeners]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -254,7 +254,7 @@ class CoinsRepo { List assets, { bool notifyListeners = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { @@ -400,14 +400,14 @@ class CoinsRepo { /// activated coins and retry failed activations with exponential backoff. /// /// **Retry Configuration:** - /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (30 attempts ≈ 3 minutes) + /// - Default: 500ms → 1s → 2s → 4s → 8s → 10s → 10s... (15 attempts ≈ 105 seconds) /// - Configurable via [maxRetryAttempts], [initialRetryDelay], and [maxRetryDelay] /// /// **Parameters:** /// - [coins]: List of coins to activate /// - [notify]: Whether to broadcast state changes to listeners (default: true) /// - [addToWalletMetadata]: Whether to add assets to wallet metadata (default: true) - /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 30) + /// - [maxRetryAttempts]: Maximum number of retry attempts (default: 15) /// - [initialRetryDelay]: Initial delay between retries (default: 500ms) /// - [maxRetryDelay]: Maximum delay between retries (default: 10s) /// @@ -427,7 +427,7 @@ class CoinsRepo { List coins, { bool notify = true, bool addToWalletMetadata = true, - int maxRetryAttempts = 30, + int maxRetryAttempts = 15, Duration initialRetryDelay = const Duration(milliseconds: 500), Duration maxRetryDelay = const Duration(seconds: 10), }) async { diff --git a/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart new file mode 100644 index 0000000000..aa4ffd11f9 --- /dev/null +++ b/test_integration/bloc/cex_market_data/common/update_frequency_backoff_strategy_integration_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy Integration Tests', () { + test('should demonstrate realistic backoff progression over time', () { + final strategy = UpdateFrequencyBackoffStrategy(); + final List actualIntervals = []; + + // Simulate 20 update attempts + for (int i = 0; i < 20; i++) { + actualIntervals.add(strategy.getNextInterval()); + } + + // Verify the pattern: 2min pairs, then 4min pairs, then 8min pairs, etc. + expect(actualIntervals[0], const Duration(minutes: 2)); // Attempt 0 + expect(actualIntervals[1], const Duration(minutes: 2)); // Attempt 1 + expect(actualIntervals[2], const Duration(minutes: 4)); // Attempt 2 + expect(actualIntervals[3], const Duration(minutes: 4)); // Attempt 3 + expect(actualIntervals[4], const Duration(minutes: 8)); // Attempt 4 + expect(actualIntervals[5], const Duration(minutes: 8)); // Attempt 5 + expect(actualIntervals[6], const Duration(minutes: 16)); // Attempt 6 + expect(actualIntervals[7], const Duration(minutes: 16)); // Attempt 7 + expect(actualIntervals[8], const Duration(minutes: 32)); // Attempt 8 + expect(actualIntervals[9], const Duration(minutes: 32)); // Attempt 9 + expect(actualIntervals[10], const Duration(minutes: 60)); // Capped at 1 hour + expect(actualIntervals[11], const Duration(minutes: 60)); // Capped at 1 hour + + // Verify that all subsequent intervals are capped at max + for (int i = 12; i < actualIntervals.length; i++) { + expect(actualIntervals[i], const Duration(minutes: 60)); + } + }); + + test('should reduce API calls over time compared to fixed interval', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // Calculate total time and API calls over 24 hours with backoff strategy + const simulationDuration = Duration(hours: 24); + int backoffApiCalls = 0; + Duration totalBackoffTime = Duration.zero; + + while (totalBackoffTime < simulationDuration) { + final interval = strategy.getNextInterval(); + totalBackoffTime += interval; + backoffApiCalls++; + } + + // Calculate API calls with fixed 2-minute interval + const fixedInterval = Duration(minutes: 2); + final fixedApiCalls = simulationDuration.inMinutes ~/ fixedInterval.inMinutes; + + // Backoff strategy should make significantly fewer API calls + expect(backoffApiCalls, lessThan(fixedApiCalls)); + expect(backoffApiCalls, lessThan(fixedApiCalls * 0.5)); // Less than 50% of fixed calls + + print('Fixed interval (2min): $fixedApiCalls API calls in 24h'); + print('Backoff strategy: $backoffApiCalls API calls in 24h'); + print('Reduction: ${((fixedApiCalls - backoffApiCalls) / fixedApiCalls * 100).toStringAsFixed(1)}%'); + }); + + test('should recover quickly after reset', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // Advance to high attempt count + for (int i = 0; i < 10; i++) { + strategy.getNextInterval(); + } + + // Should be at a high interval + expect(strategy.getCurrentInterval(), greaterThan(const Duration(minutes: 10))); + + // Reset and verify quick recovery + strategy.reset(); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + }); + + test('should handle custom intervals for different use cases', () { + // Test for a more aggressive backoff (shorter max interval) + final aggressiveStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 10), + ); + + // Test for a more conservative backoff (longer base interval) + final conservativeStrategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 5), + maxInterval: const Duration(hours: 2), + ); + + // Aggressive should reach max quickly (after 6 attempts: 1,1,2,2,4,4,8...) + for (int i = 0; i < 6; i++) { + aggressiveStrategy.getNextInterval(); + } + expect(aggressiveStrategy.getCurrentInterval(), const Duration(minutes: 8)); + + // Conservative should start and progress more slowly + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 5)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + expect(conservativeStrategy.getNextInterval(), const Duration(minutes: 10)); + }); + + test('should be suitable for portfolio update scenarios', () { + final strategy = UpdateFrequencyBackoffStrategy(); + + // First hour of updates (user just logged in) + final firstHourIntervals = []; + Duration elapsed = Duration.zero; + const oneHour = Duration(hours: 1); + + while (elapsed < oneHour) { + final interval = strategy.getNextInterval(); + firstHourIntervals.add(interval); + elapsed += interval; + } + + // Should have frequent updates in the first hour + expect(firstHourIntervals.length, greaterThan(5)); + expect(firstHourIntervals.length, lessThan(30)); // But not too frequent + + // First few updates should be relatively quick + expect(firstHourIntervals[0], const Duration(minutes: 2)); + expect(firstHourIntervals[1], const Duration(minutes: 2)); + + // Later updates should be less frequent + final lastInterval = firstHourIntervals.last; + expect(lastInterval, greaterThan(const Duration(minutes: 2))); + + print('Updates in first hour: ${firstHourIntervals.length}'); + print('Intervals: ${firstHourIntervals.map((d) => '${d.inMinutes}min').join(', ')}'); + }); + }); +} \ No newline at end of file diff --git a/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart new file mode 100644 index 0000000000..6dbb7c5fbc --- /dev/null +++ b/test_units/bloc/cex_market_data/common/update_frequency_backoff_strategy_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/cex_market_data/common/update_frequency_backoff_strategy.dart'; + +void main() { + group('UpdateFrequencyBackoffStrategy', () { + late UpdateFrequencyBackoffStrategy strategy; + + setUp(() { + strategy = UpdateFrequencyBackoffStrategy(); + }); + + test('should start with attempt count 0', () { + expect(strategy.attemptCount, 0); + }); + + test('should return base interval for first two attempts', () { + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.attemptCount, 1); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + expect(strategy.getNextInterval(), const Duration(minutes: 2)); + expect(strategy.attemptCount, 2); + }); + + test('should double interval for next pair of attempts', () { + // Skip first two attempts + strategy.getNextInterval(); // 2 min + strategy.getNextInterval(); // 2 min + + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + expect(strategy.attemptCount, 3); + + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + expect(strategy.getNextInterval(), const Duration(minutes: 4)); + expect(strategy.attemptCount, 4); + }); + + test('should follow exponential backoff pattern: 2,2,4,4,8,8,16,16', () { + final expectedIntervals = [ + const Duration(minutes: 2), // attempt 0 + const Duration(minutes: 2), // attempt 1 + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + const Duration(minutes: 16), // attempt 6 + const Duration(minutes: 16), // attempt 7 + ]; + + for (int i = 0; i < expectedIntervals.length; i++) { + expect( + strategy.getNextInterval(), + expectedIntervals[i], + reason: 'Attempt $i should return ${expectedIntervals[i]}', + ); + } + }); + + test('should cap at maximum interval', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 5), + ); + + // Skip to high attempt count to reach max + for (int i = 0; i < 10; i++) { + strategy.getNextInterval(); + } + + // Should be capped at 5 minutes + expect(strategy.getCurrentInterval(), const Duration(minutes: 5)); + }); + + test('should reset to initial state', () { + // Make some attempts + strategy.getNextInterval(); + strategy.getNextInterval(); + strategy.getNextInterval(); + + expect(strategy.attemptCount, 3); + expect(strategy.getCurrentInterval(), const Duration(minutes: 4)); + + // Reset + strategy.reset(); + + expect(strategy.attemptCount, 0); + expect(strategy.getCurrentInterval(), const Duration(minutes: 2)); + }); + + test('should always return true for shouldUpdateCache', () { + // Test for various attempt counts + for (int i = 0; i < 10; i++) { + expect(strategy.shouldUpdateCache(), true); + strategy.getNextInterval(); + } + }); + + test('should preview next intervals without changing state', () { + // Start at attempt count 0 + expect(strategy.attemptCount, 0); + + final preview = strategy.previewNextIntervals(6); + + // State should be unchanged + expect(strategy.attemptCount, 0); + + // Preview should show correct intervals + expect(preview, [ + const Duration(minutes: 2), // attempt 0 + const Duration(minutes: 2), // attempt 1 + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + ]); + }); + + test('should preview intervals from current position', () { + // Advance to attempt 2 + strategy.getNextInterval(); // 2 min + strategy.getNextInterval(); // 2 min + + expect(strategy.attemptCount, 2); + + final preview = strategy.previewNextIntervals(4); + + // Should show intervals starting from attempt 2 + expect(preview, [ + const Duration(minutes: 4), // attempt 2 + const Duration(minutes: 4), // attempt 3 + const Duration(minutes: 8), // attempt 4 + const Duration(minutes: 8), // attempt 5 + ]); + + // State should be unchanged + expect(strategy.attemptCount, 2); + }); + + test('should handle custom base and max intervals', () { + strategy = UpdateFrequencyBackoffStrategy( + baseInterval: const Duration(minutes: 1), + maxInterval: const Duration(minutes: 3), + ); + + final intervals = [ + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 1min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 2min + strategy.getNextInterval(), // 3min (capped at max) + ]; + + expect(intervals, [ + const Duration(minutes: 1), + const Duration(minutes: 1), + const Duration(minutes: 2), + const Duration(minutes: 2), + const Duration(minutes: 3), // Capped + ]); + }); + }); +} \ No newline at end of file From a82e02230a6c84e4e09e5ed5f1932bd4558edb41 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 1 Oct 2025 04:25:43 +0200 Subject: [PATCH 20/24] perf(dex): reduce overhead in coin selection and filtering --- assets/translations/en.json | 5 +- lib/bloc/bridge_form/bridge_repository.dart | 1 - .../profit_loss/profit_loss_event.dart | 7 +- lib/blocs/orderbook_bloc.dart | 15 +- lib/generated/codegen_loader.g.dart | 3 +- lib/model/coin_utils.dart | 25 +- lib/model/orderbook/order.dart | 47 ++-- .../form/tables/coins_table/coins_table.dart | 22 +- .../dex/simple/form/tables/table_utils.dart | 197 ++++++++++----- .../zhtlc/zhtlc_activation_status_bar.dart | 10 +- .../simple/form/tables/table_utils_test.dart | 226 ++++++++++++++++++ 11 files changed, 422 insertions(+), 136 deletions(-) create mode 100644 test_units/tests/views/dex/simple/form/tables/table_utils_test.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 8ea2d7b875..1881ed515d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -784,8 +784,9 @@ "zhtlcPreparingDownload": "Preparing download...", "zhtlcErrorSettingUpZcash": "Error setting up Zcash parameters: {}", "zhtlcDateSyncHint": "Selecting a date further in the past can significantly increase the activation time. \nActivation can take a little while the first time to download block cache data.\n\nTransactions and balance prior to the sync date may be missing.\nOften this can be restored by sending in and out new transactions", - "activatingZhtlcCoins": { + "zhtlcActivating": { "one": "Activating ZHTLC coin. Please do not close the app or tab until complete.", "other": "Activating ZHTLC coins. Please do not close the app or tab until complete." - } + }, + "zhtlcActivationWarning": "This may take from a few minutes to a few hours, depending on your sync params and how long since your last sync." } \ No newline at end of file diff --git a/lib/bloc/bridge_form/bridge_repository.dart b/lib/bloc/bridge_form/bridge_repository.dart index 5b728a7a69..c1db2114d8 100644 --- a/lib/bloc/bridge_form/bridge_repository.dart +++ b/lib/bloc/bridge_form/bridge_repository.dart @@ -55,7 +55,6 @@ class BridgeRepository { Future getAvailableTickers() async { List coins = _coinsRepository.getKnownCoins(); coins = removeWalletOnly(coins); - coins = removeSuspended(coins, await _kdfSdk.auth.isSignedIn()); final CoinsByTicker coinsByTicker = convertToCoinsByTicker(coins); final CoinsByTicker multiProtocolCoins = diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart index 15049b2f8e..f6ff6591d0 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart @@ -17,13 +17,11 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { required this.fiatCoinId, required this.selectedPeriod, required this.walletId, - this.updateFrequency = const Duration(minutes: 2), }); final List coins; final String fiatCoinId; final Duration selectedPeriod; - final Duration updateFrequency; final String walletId; @override @@ -32,19 +30,16 @@ class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { fiatCoinId, selectedPeriod, walletId, - updateFrequency, ]; } class ProfitLossPortfolioPeriodChanged extends ProfitLossEvent { const ProfitLossPortfolioPeriodChanged({ required this.selectedPeriod, - this.updateFrequency = const Duration(minutes: 2), }); final Duration selectedPeriod; - final Duration updateFrequency; @override - List get props => [selectedPeriod, updateFrequency]; + List get props => [selectedPeriod]; } diff --git a/lib/blocs/orderbook_bloc.dart b/lib/blocs/orderbook_bloc.dart index 4972c22032..2af564dd90 100644 --- a/lib/blocs/orderbook_bloc.dart +++ b/lib/blocs/orderbook_bloc.dart @@ -88,24 +88,15 @@ class OrderbookBloc implements BlocBase { final result = OrderbookResult(response: response); subscription.initialData = result; subscription.sink.add(result); - } on GeneralErrorResponse catch (error) { - final message = error.error ?? error.toString(); - log( - 'Orderbook request failed for pair $pair: $message', - path: 'OrderbookBloc._fetchOrderbook', - isError: true, - ).ignore(); - final result = OrderbookResult(error: message); - subscription.initialData = result; - subscription.sink.add(result); } catch (e, s) { log( - 'Unexpected orderbook error for pair $pair: $e', + // Exception message can contain RPC pass, so avoid displaying it and logging it + 'Unexpected orderbook error for pair $pair', path: 'OrderbookBloc._fetchOrderbook', trace: s, isError: true, ).ignore(); - final result = OrderbookResult(error: e.toString()); + final result = OrderbookResult(error: 'Unexpected error for pair $pair'); subscription.initialData = result; subscription.sink.add(result); } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 8831f9808d..c7e2c29f8b 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -774,6 +774,7 @@ abstract class LocaleKeys { static const zhtlcPreparingDownload = 'zhtlcPreparingDownload'; static const zhtlcErrorSettingUpZcash = 'zhtlcErrorSettingUpZcash'; static const zhtlcDateSyncHint = 'zhtlcDateSyncHint'; - static const activatingZhtlcCoins = 'activatingZhtlcCoins'; + static const zhtlcActivating = 'zhtlcActivating'; + static const zhtlcActivationWarning = 'zhtlcActivationWarning'; } diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart index d5e997201d..afc400cb84 100644 --- a/lib/model/coin_utils.dart +++ b/lib/model/coin_utils.dart @@ -11,7 +11,7 @@ import 'package:web_dex/shared/utils/utils.dart'; /// 2. If no balance, sort by priority (higher priority first) /// 3. If same priority, sort alphabetically List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { - final List list = List.from(coins); + final List list = List.of(coins); list.sort((a, b) { final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; @@ -36,7 +36,7 @@ List sortByPriorityAndBalance(List coins, KomodoDefiSdk sdk) { } List sortFiatBalance(List coins, KomodoDefiSdk sdk) { - final List list = List.from(coins); + final List list = List.of(coins); list.sort((a, b) { final double usdBalanceA = a.lastKnownUsdBalance(sdk) ?? 0.00; final double usdBalanceB = b.lastKnownUsdBalance(sdk) ?? 0.00; @@ -57,28 +57,11 @@ List sortFiatBalance(List coins, KomodoDefiSdk sdk) { } List removeTestCoins(List coins) { - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.isTestCoin); - - return list; + return coins.where((Coin coin) => !coin.isTestCoin).toList(); } List removeWalletOnly(List coins) { - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.walletOnly); - - return list; -} - -List removeSuspended(List coins, bool isLoggedIn) { - if (!isLoggedIn) return coins; - final List list = List.from(coins); - - list.removeWhere((Coin coin) => coin.isSuspended); - - return list; + return coins.where((Coin coin) => !coin.walletOnly).toList(); } Map> removeSingleProtocol(Map> group) { diff --git a/lib/model/orderbook/order.dart b/lib/model/orderbook/order.dart index e039c90197..92be3fcaf7 100644 --- a/lib/model/orderbook/order.dart +++ b/lib/model/orderbook/order.dart @@ -49,22 +49,19 @@ class Order { required String rel, required OrderDirection direction, }) { - final Rational? price = _numericValueToRational(info.price); + final Rational? price = info.price?.toRational(); - final Rational? maxVolume = _numericValueToRational( - info.baseMaxVolume ?? info.baseMaxVolumeAggregated, - ); + final Rational? maxVolume = + (info.baseMaxVolume ?? info.baseMaxVolumeAggregated)?.toRational(); if (price == null || maxVolume == null) { throw ArgumentError('Invalid price or maxVolume in OrderInfo'); } - final Rational? minVolume = _numericValueToRational( - info.baseMinVolume, - ); + final Rational? minVolume = info.baseMinVolume?.toRational(); final Rational? minVolumeRel = - _numericValueToRational(info.relMinVolume) ?? + info.relMinVolume?.toRational() ?? (minVolume != null ? minVolume * price : null); return Order( @@ -94,33 +91,29 @@ class Order { bool get isBid => direction == OrderDirection.bid; bool get isAsk => direction == OrderDirection.ask; +} + +enum OrderDirection { bid, ask } + +// This const is used to identify and highlight newly created +// order preview in maker form orderbook (instead of isTarget flag) +final String orderPreviewUuid = const Uuid().v1(); - static Rational? _numericValueToRational(NumericValue? value) { - if (value == null) return null; - if (value.rational != null) { - return value.rational; +extension NumericValueExtension on NumericValue { + Rational toRational() { + if (rational != null) { + return rational!; } - final fraction = value.fraction; if (fraction != null) { - final fractionRat = fract2rat(fraction.toJson(), false); + final fractionRat = fract2rat(fraction!.toJson(), false); if (fractionRat != null) { return fractionRat; } } - final decimal = value.decimal.trim(); + final decimal = this.decimal.trim(); if (decimal.isEmpty) { - return null; - } - try { - return Rational.parse(decimal); - } catch (_) { - return null; + throw ArgumentError('NumericValue has empty decimal string'); } + return Rational.parse(decimal); } } - -enum OrderDirection { bid, ask } - -// This const is used to identify and highlight newly created -// order preview in maker form orderbook (instead of isTarget flag) -final String orderPreviewUuid = const Uuid().v1(); diff --git a/lib/views/dex/simple/form/tables/coins_table/coins_table.dart b/lib/views/dex/simple/form/tables/coins_table/coins_table.dart index c8140c10e4..04c6d392e1 100644 --- a/lib/views/dex/simple/form/tables/coins_table/coins_table.dart +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:komodo_ui/komodo_ui.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/views/dex/common/front_plate.dart'; import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table_content.dart'; @@ -22,6 +23,13 @@ class CoinsTable extends StatefulWidget { class _CoinsTableState extends State { String? _searchTerm; + late final Debouncer _searchDebouncer; + + @override + void initState() { + super.initState(); + _searchDebouncer = Debouncer(duration: const Duration(milliseconds: 200)); + } @override Widget build(BuildContext context) { @@ -38,8 +46,12 @@ class _CoinsTableState extends State { child: TableSearchField( height: 30, onChanged: (String value) { - if (_searchTerm == value) return; - setState(() => _searchTerm = value); + final nextValue = value; + _searchDebouncer.run(() { + if (!mounted) return; + if (_searchTerm == nextValue) return; + setState(() => _searchTerm = nextValue); + }); }, ), ), @@ -54,4 +66,10 @@ class _CoinsTableState extends State { ), ); } + + @override + void dispose() { + _searchDebouncer.dispose(); + super.dispose(); + } } diff --git a/lib/views/dex/simple/form/tables/table_utils.dart b/lib/views/dex/simple/form/tables/table_utils.dart index ac11a02529..62a373a9d5 100644 --- a/lib/views/dex/simple/form/tables/table_utils.dart +++ b/lib/views/dex/simple/form/tables/table_utils.dart @@ -1,14 +1,13 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; -import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' show AssetId; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; 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'; List prepareCoinsForTable( BuildContext context, @@ -16,12 +15,14 @@ List prepareCoinsForTable( String? searchString, { bool testCoinsEnabled = true, }) { - final authBloc = RepositoryProvider.of(context); - coins = List.from(coins); - if (!testCoinsEnabled) coins = removeTestCoins(coins); + final sdk = RepositoryProvider.of(context); + + coins = List.of(coins); +if (!testCoinsEnabled) { + coins = removeTestCoins(coins); + } coins = removeWalletOnly(coins); - coins = removeSuspended(coins, authBloc.state.isSignedIn); - coins = sortByPriorityAndBalance(coins, GetIt.I()); + coins = sortByPriorityAndBalance(coins, sdk); coins = filterCoinsByPhrase(coins, searchString ?? '').toList(); return coins; } @@ -30,32 +31,56 @@ List prepareOrdersForTable( BuildContext context, Map>? orders, String? searchString, - AuthorizeMode mode, { + AuthorizeMode _mode, { bool testCoinsEnabled = true, + Coin? Function(String)? coinLookup, }) { if (orders == null) return []; - final List sorted = _sortBestOrders(context, orders); - if (sorted.isEmpty) return []; + final caches = buildOrderCoinCaches(context, orders, coinLookup: coinLookup); + + final ordersByAssetId = caches.ordersByAssetId; + final coinsByAssetId = caches.coinsByAssetId; + final assetIdByAbbr = caches.assetIdByAbbr; + + final List sorted = _sortBestOrders( + ordersByAssetId, + coinsByAssetId, + ); + if (sorted.isEmpty) { + return []; + } if (!testCoinsEnabled) { - removeTestCoinOrders(sorted, context); - if (sorted.isEmpty) return []; + removeTestCoinOrders( + sorted, + ordersByAssetId, + coinsByAssetId, + assetIdByAbbr, + ); + if (sorted.isEmpty) { + return []; + } } - removeSuspendedCoinOrders(sorted, mode, context); - if (sorted.isEmpty) return []; - - removeWalletOnlyCoinOrders(sorted, context); - if (sorted.isEmpty) return []; + removeWalletOnlyCoinOrders( + sorted, + ordersByAssetId, + coinsByAssetId, + assetIdByAbbr, + ); + if (sorted.isEmpty) { + return []; + } final String? filter = searchString?.toLowerCase(); if (filter == null || filter.isEmpty) { return sorted; } - final coinsRepository = RepositoryProvider.of(context); final List filtered = sorted.where((order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return false; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return false; return compareCoinByPhrase(coin, filter); }).toList(); @@ -63,65 +88,115 @@ List prepareOrdersForTable( return filtered; } -List _sortBestOrders( - BuildContext context, Map> unsorted) { - if (unsorted.isEmpty) return []; - - final coinsRepository = RepositoryProvider.of(context); - final List sorted = []; - unsorted.forEach((ticker, list) { - if (coinsRepository.getCoin(list[0].coin) == null) return; - sorted.add(list[0]); +({ + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +}) +buildOrderCoinCaches( + BuildContext context, + Map> orders, { + Coin? Function(String)? coinLookup, +}) { + final Coin? Function(String) resolveCoin = + coinLookup ?? RepositoryProvider.of(context).getCoin; + + final ordersByAssetId = {}; + final coinsByAssetId = {}; + final assetIdByAbbr = {}; + + orders.forEach((_, list) { + if (list.isEmpty) return; + final BestOrder order = list[0]; + final Coin? coin = resolveCoin(order.coin); + if (coin == null) return; + + final AssetId assetId = coin.assetId; + ordersByAssetId[assetId] = order; + coinsByAssetId[assetId] = coin; + assetIdByAbbr[coin.abbr] = assetId; }); - sorted.sort((a, b) { - final Coin? coinA = coinsRepository.getCoin(a.coin); - final Coin? coinB = coinsRepository.getCoin(b.coin); - if (coinA == null || coinB == null) return 0; - - final double fiatPriceA = getFiatAmount(coinA, a.price); - final double fiatPriceB = getFiatAmount(coinB, b.price); + return ( + ordersByAssetId: ordersByAssetId, + coinsByAssetId: coinsByAssetId, + assetIdByAbbr: assetIdByAbbr, + ); +} - if (fiatPriceA > fiatPriceB) return -1; - if (fiatPriceA < fiatPriceB) return 1; +List _sortBestOrders( + Map ordersByAssetId, + Map coinsByAssetId, +) { + if (ordersByAssetId.isEmpty) return []; + final entries = + <({AssetId assetId, BestOrder order, Coin coin, double fiatPrice})>[]; + + ordersByAssetId.forEach((assetId, order) { + final Coin? coin = coinsByAssetId[assetId]; + if (coin == null) return; + + final Decimal? usdPrice = coin.usdPrice?.price; + final double fiatPrice = + order.price.toDouble() * (usdPrice?.toDouble() ?? 0.0); + entries.add(( + assetId: assetId, + order: order, + coin: coin, + fiatPrice: fiatPrice, + )); + }); - return coinA.abbr.compareTo(coinB.abbr); + entries.sort((a, b) { + final int fiatComparison = b.fiatPrice.compareTo(a.fiatPrice); + if (fiatComparison != 0) return fiatComparison; + return a.coin.abbr.compareTo(b.coin.abbr); }); - return sorted; + final result = entries.map((entry) => entry.order).toList(); + return result; } -void removeSuspendedCoinOrders( +void removeWalletOnlyCoinOrders( List orders, - AuthorizeMode authorizeMode, - BuildContext context, + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, ) { - if (authorizeMode == AuthorizeMode.noLogin) return; - final coinsRepository = RepositoryProvider.of(context); orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return true; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return true; - return coin.isSuspended; + final bool shouldRemove = coin.walletOnly; + if (shouldRemove) { + ordersByAssetId.remove(assetId); + coinsByAssetId.remove(assetId); + assetIdByAbbr.remove(order.coin); + } + return shouldRemove; }); } -void removeWalletOnlyCoinOrders(List orders, BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); - orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); - if (coin == null) return true; - - return coin.walletOnly; - }); -} - -void removeTestCoinOrders(List orders, BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); +void removeTestCoinOrders( + List orders, + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, +) { orders.removeWhere((BestOrder order) { - final Coin? coin = coinsRepository.getCoin(order.coin); + final AssetId? assetId = assetIdByAbbr[order.coin]; + if (assetId == null) return true; + final Coin? coin = coinsByAssetId[assetId]; if (coin == null) return true; - return coin.isTestCoin; + final bool shouldRemove = coin.isTestCoin; + if (shouldRemove) { + ordersByAssetId.remove(assetId); + coinsByAssetId.remove(assetId); + assetIdByAbbr.remove(order.coin); + } + return shouldRemove; }); } diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index e7b67e35e6..2a9b65da9b 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -114,7 +114,6 @@ class _ZhtlcActivationStatusBarState extends State { return const SizedBox.shrink(); } - final coinNames = activeStatuses.map((entry) => entry.key.id).join(', '); final coinCount = activeStatuses.length; return Padding( padding: const EdgeInsets.only(bottom: 10), @@ -149,13 +148,18 @@ class _ZhtlcActivationStatusBarState extends State { const SizedBox(width: 12), Expanded( child: AutoScrollText( - text: LocaleKeys.activatingZhtlcCoins.plural(coinCount), + text: LocaleKeys.zhtlcActivating.plural(coinCount), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).textTheme.bodySmall?.color, fontWeight: FontWeight.w500, ), ), ), + Expanded( + child: AutoScrollText( + text: LocaleKeys.zhtlcActivationWarning.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), ], ), const SizedBox(height: 8), diff --git a/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart b/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart new file mode 100644 index 0000000000..1031f9015c --- /dev/null +++ b/test_units/tests/views/dex/simple/form/tables/table_utils_test.dart @@ -0,0 +1,226 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart' + show AssetChainId, AssetId, CoinSubClass; +import 'package:komodo_defi_types/src/assets/asset_symbol.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/cex_price.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/views/dex/simple/form/tables/table_utils.dart'; + +Coin _buildCoin( + String abbr, { + double usdPrice = 0, + bool walletOnly = false, + bool isTestCoin = false, + int priority = 0, +}) { + final assetId = AssetId( + id: abbr, + name: '$abbr Coin', + symbol: AssetSymbol(assetConfigId: abbr), + chainId: AssetChainId(chainId: 1), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + return Coin( + type: CoinType.utxo, + abbr: abbr, + id: assetId, + name: '$abbr Coin', + explorerUrl: 'https://example.com/$abbr', + explorerTxUrl: 'https://example.com/$abbr/tx', + explorerAddressUrl: 'https://example.com/$abbr/address', + protocolType: 'UTXO', + protocolData: null, + isTestCoin: isTestCoin, + logoImageUrl: null, + coingeckoId: null, + fallbackSwapContract: null, + priority: priority, + state: CoinState.active, + swapContractAddress: null, + walletOnly: walletOnly, + mode: CoinMode.standard, + usdPrice: CexPrice( + assetId: assetId, + price: Decimal.parse(usdPrice.toString()), + change24h: Decimal.zero, + lastUpdated: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); +} + +BestOrder _buildOrder(String coin, int price) { + return BestOrder( + price: Rational.fromInt(price), + maxVolume: Rational.fromInt(1), + minVolume: Rational.fromInt(1), + coin: coin, + address: OrderAddress.transparent(coin.toLowerCase()), + uuid: '$coin-$price', + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('buildOrderCoinCaches', () { + testWidgets('creates aligned caches for orders and coins', (tester) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + final kmd = _buildCoin('KMD', usdPrice: 1); + final coins = {'BTC': btc, 'KMD': kmd}; + final coinLookup = (String abbr) => coins[abbr]; + + final orders = >{ + 'BTC-KMD': [_buildOrder('BTC', 1)], + 'KMD-BTC': [_buildOrder('KMD', 2)], + }; + + late ({ + Map ordersByAssetId, + Map coinsByAssetId, + Map assetIdByAbbr, + }) + caches; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + caches = buildOrderCoinCaches( + context, + orders, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(caches.ordersByAssetId.length, 2); + expect(caches.coinsByAssetId.length, 2); + expect(caches.assetIdByAbbr['BTC'], btc.assetId); + expect(caches.ordersByAssetId[btc.assetId]?.uuid, 'BTC-1'); + }); + }); + + group('prepareOrdersForTable', () { + testWidgets('sorts by fiat value and filters wallet/test coins', ( + tester, + ) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + final kmd = _buildCoin('KMD', usdPrice: 1, walletOnly: true); + final tbtc = _buildCoin('TBTC', usdPrice: 25_000, isTestCoin: true); + final coins = {'BTC': btc, 'KMD': kmd, 'TBTC': tbtc}; + final coinLookup = (String abbr) => coins[abbr]; + + final orders = >{ + 'BTC-KMD': [_buildOrder('BTC', 1)], + 'KMD-BTC': [_buildOrder('KMD', 2)], + 'TBTC-KMD': [_buildOrder('TBTC', 3)], + }; + + late List sorted; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + sorted = prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + testCoinsEnabled: false, + coinLookup: coinLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(sorted, hasLength(1)); + expect(sorted.single.coin, 'BTC'); + }); + + testWidgets('uses fewer coin lookups than the legacy approach', ( + tester, + ) async { + final btc = _buildCoin('BTC', usdPrice: 30_000); + final kmd = _buildCoin('KMD', usdPrice: 1); + final coins = {'BTC': btc, 'KMD': kmd}; + + final orders = >{ + 'pair-1': [_buildOrder('BTC', 1)], + 'pair-2': [_buildOrder('KMD', 100)], + }; + + final legacyCalls = {}; + final optimisedCalls = {}; + + Coin? legacyLookup(String abbr) { + legacyCalls[abbr] = (legacyCalls[abbr] ?? 0) + 1; + return coins[abbr]; + } + + Coin? optimisedLookup(String abbr) { + optimisedCalls[abbr] = (optimisedCalls[abbr] ?? 0) + 1; + return coins[abbr]; + } + + List legacyPrepare( + Map> input, + Coin? Function(String) lookup, + ) { + final result = []; + input.forEach((_, list) { + if (list.isEmpty) return; + final order = list.first; + final coin = lookup(order.coin); + if (coin == null) return; + result.add(order); + }); + result.sort((a, b) { + final coinA = lookup(a.coin); + final coinB = lookup(b.coin); + final fiatA = + a.price.toDouble() * (coinA?.usdPrice?.price?.toDouble() ?? 0.0); + final fiatB = + b.price.toDouble() * (coinB?.usdPrice?.price?.toDouble() ?? 0.0); + return fiatB.compareTo(fiatA); + }); + return result; + } + + legacyPrepare(orders, legacyLookup); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + prepareOrdersForTable( + context, + orders, + null, + AuthorizeMode.noLogin, + coinLookup: optimisedLookup, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(legacyCalls['BTC']! > optimisedCalls['BTC']!, isTrue); + expect(legacyCalls['KMD']! > optimisedCalls['KMD']!, isTrue); + }); + }); +} From ccfae6af339ae7a4cfea8569df8c46f99868ebe3 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 1 Oct 2025 05:09:17 +0200 Subject: [PATCH 21/24] fix(dex): only update sell amount on order selection if no value present also cap to the available sell amount --- lib/bloc/taker_form/taker_bloc.dart | 345 +++++++++--------- lib/blocs/orderbook_bloc.dart | 2 +- .../zhtlc/zhtlc_activation_status_bar.dart | 5 + .../zhtlc/zhtlc_configuration_dialog.dart | 3 +- 4 files changed, 186 insertions(+), 169 deletions(-) diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index 04c5b47435..fdabd2e888 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' show min; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -32,10 +33,10 @@ class TakerBloc extends Bloc { required DexRepository dexRepository, required CoinsRepo coinsRepository, required KomodoDefiSdk kdfSdk, - }) : _dexRepo = dexRepository, - _coinsRepo = coinsRepository, - _sdk = kdfSdk, - super(TakerState.initial()) { + }) : _dexRepo = dexRepository, + _coinsRepo = coinsRepository, + _sdk = kdfSdk, + super(TakerState.initial()) { _validator = TakerValidator( bloc: this, coinsRepo: _coinsRepo, @@ -76,6 +77,10 @@ class TakerBloc extends Bloc { if (event != null && state.step == TakerStep.confirm) { add(TakerBackButtonClick()); } + if (event == null) { + add(TakerClear()); + add(TakerSetDefaults()); + } _isLoggedIn = event != null; }); } @@ -91,18 +96,20 @@ class TakerBloc extends Bloc { late StreamSubscription _authorizationSubscription; Future _onStartSwap( - TakerStartSwap event, Emitter emit) async { - emit(state.copyWith( - inProgress: () => true, - )); - - final SellResponse response = await _dexRepo.sell(SellRequest( - base: state.sellCoin!.abbr, - rel: state.selectedOrder!.coin, - volume: state.sellAmount!, - price: state.selectedOrder!.price, - orderType: SellBuyOrderType.fillOrKill, - )); + TakerStartSwap event, + Emitter emit, + ) async { + emit(state.copyWith(inProgress: () => true)); + + final SellResponse response = await _dexRepo.sell( + SellRequest( + base: state.sellCoin!.abbr, + rel: state.selectedOrder!.coin, + volume: state.sellAmount!, + price: state.selectedOrder!.price, + orderType: SellBuyOrderType.fillOrKill, + ), + ); if (response.error != null) { add(TakerAddError(DexFormError(error: response.error!.message))); @@ -110,39 +117,37 @@ class TakerBloc extends Bloc { final String? uuid = response.result?.uuid; - emit(state.copyWith( - inProgress: uuid == null ? () => false : null, - swapUuid: () => uuid, - )); + emit( + state.copyWith( + inProgress: uuid == null ? () => false : null, + swapUuid: () => uuid, + ), + ); } void _onBackButtonClick( TakerBackButtonClick event, Emitter emit, ) { - emit(state.copyWith( - step: () => TakerStep.form, - errors: () => [], - )); + emit(state.copyWith(step: () => TakerStep.form, errors: () => [])); } Future _onFormSubmitClick( TakerFormSubmitClick event, Emitter emit, ) async { - emit(state.copyWith( - inProgress: () => true, - autovalidate: () => true, - )); + emit(state.copyWith(inProgress: () => true, autovalidate: () => true)); await pauseWhile(() => _waitingForWallet || _activatingAssets); final bool isValid = await _validator.validate(); - emit(state.copyWith( - inProgress: () => false, - step: () => isValid ? TakerStep.confirm : TakerStep.form, - )); + emit( + state.copyWith( + inProgress: () => false, + step: () => isValid ? TakerStep.confirm : TakerStep.form, + ), + ); } void _onAmountButtonClick( @@ -152,8 +157,10 @@ class TakerBloc extends Bloc { final Rational? maxSellAmount = state.maxSellAmount; if (maxSellAmount == null) return; - final Rational sellAmount = - getFractionOfAmount(maxSellAmount, event.fraction); + final Rational sellAmount = getFractionOfAmount( + maxSellAmount, + event.fraction, + ); add(TakerSetSellAmount(sellAmount)); } @@ -162,8 +169,9 @@ class TakerBloc extends Bloc { TakerSellAmountChange event, Emitter emit, ) { - final Rational? amount = - event.value.isNotEmpty ? Rational.parse(event.value) : null; + final Rational? amount = event.value.isNotEmpty + ? Rational.parse(event.value) + : null; if (amount == state.sellAmount) return; @@ -174,13 +182,15 @@ class TakerBloc extends Bloc { TakerSetSellAmount event, Emitter emit, ) async { - emit(state.copyWith( - sellAmount: () => event.amount, - buyAmount: () => calculateBuyAmount( - selectedOrder: state.selectedOrder, - sellAmount: event.amount, + emit( + state.copyWith( + sellAmount: () => event.amount, + buyAmount: () => calculateBuyAmount( + selectedOrder: state.selectedOrder, + sellAmount: event.amount, + ), ), - )); + ); if (state.autovalidate) { await _validator.validateForm(); @@ -190,10 +200,7 @@ class TakerBloc extends Bloc { add(TakerUpdateFees()); } - void _onAddError( - TakerAddError event, - Emitter emit, - ) { + void _onAddError(TakerAddError event, Emitter emit) { final List errorsList = List.from(state.errors); if (errorsList.any((e) => e.error == event.error.error)) { // Avoid adding duplicate errors @@ -201,43 +208,45 @@ class TakerBloc extends Bloc { } errorsList.add(event.error); - emit(state.copyWith( - errors: () => errorsList, - )); + emit(state.copyWith(errors: () => errorsList)); } - void _onClearErrors( - TakerClearErrors event, - Emitter emit, - ) { - emit(state.copyWith( - errors: () => [], - )); + void _onClearErrors(TakerClearErrors event, Emitter emit) { + emit(state.copyWith(errors: () => [])); } Future _onSelectOrder( TakerSelectOrder event, Emitter emit, ) async { - final bool switchingCoin = state.selectedOrder != null && + final bool switchingCoin = + state.selectedOrder != null && event.order != null && state.selectedOrder!.coin != event.order!.coin; - emit(state.copyWith( - selectedOrder: () => event.order, - showOrderSelector: () => false, - buyAmount: () => calculateBuyAmount( - sellAmount: state.sellAmount, - selectedOrder: event.order, + emit( + state.copyWith( + selectedOrder: () => event.order, + showOrderSelector: () => false, + buyAmount: () => calculateBuyAmount( + sellAmount: state.sellAmount, + selectedOrder: event.order, + ), + tradePreimage: () => null, + errors: () => [], + autovalidate: switchingCoin ? () => false : null, ), - tradePreimage: () => null, - errors: () => [], - autovalidate: switchingCoin ? () => false : null, - )); + ); // Auto-fill the exact maker amount when an order is selected - if (event.order != null) { - add(TakerSetSellAmount(event.order!.maxVolume)); + final hasUserSetSellAmount = + (state.sellAmount ?? Rational.zero) > Rational.zero; + if (event.order != null && !hasUserSetSellAmount) { + final maxSellAmount = state.maxSellAmount ?? Rational.zero; + final desiredSellAmount = event.order!.maxVolume < maxSellAmount + ? event.order!.maxVolume + : maxSellAmount; + add(TakerSetSellAmount(desiredSellAmount)); } if (!state.autovalidate) add(TakerVerifyOrderVolume()); @@ -263,20 +272,22 @@ class TakerBloc extends Bloc { ) async { if (event.setOnlyIfNotSet && state.sellCoin != null) return; - emit(state.copyWith( - sellCoin: () => event.coin, - showCoinSelector: () => false, - selectedOrder: () => null, - bestOrders: () => null, - sellAmount: () => null, - buyAmount: () => null, - tradePreimage: () => null, - maxSellAmount: () => null, - minSellAmount: () => null, - errors: () => [], - autovalidate: () => false, - availableBalanceState: () => AvailableBalanceState.initial, - )); + emit( + state.copyWith( + sellCoin: () => event.coin, + showCoinSelector: () => false, + selectedOrder: () => null, + bestOrders: () => null, + sellAmount: () => null, + buyAmount: () => null, + tradePreimage: () => null, + maxSellAmount: () => null, + minSellAmount: () => null, + errors: () => [], + autovalidate: () => false, + availableBalanceState: () => AvailableBalanceState.initial, + ), + ); add(TakerUpdateBestOrders(autoSelectOrderAbbr: event.autoSelectOrderAbbr)); @@ -291,9 +302,7 @@ class TakerBloc extends Bloc { ) async { final Coin? coin = state.sellCoin; - emit(state.copyWith( - bestOrders: () => null, - )); + emit(state.copyWith(bestOrders: () => null)); if (coin == null) return; @@ -308,8 +317,9 @@ class TakerBloc extends Bloc { /// Unsupported coins like ARRR cause downstream errors, so we need to /// remove them from the list here - bestOrders.result - ?.removeWhere((coinId, _) => excludedAssetList.contains(coinId)); + bestOrders.result?.removeWhere( + (coinId, _) => excludedAssetList.contains(coinId), + ); emit(state.copyWith(bestOrders: () => bestOrders)); @@ -326,10 +336,12 @@ class TakerBloc extends Bloc { TakerCoinSelectorClick event, Emitter emit, ) { - emit(state.copyWith( - showCoinSelector: () => !state.showCoinSelector, - showOrderSelector: () => false, - )); + emit( + state.copyWith( + showCoinSelector: () => !state.showCoinSelector, + showOrderSelector: () => false, + ), + ); } Future _onOrderSelectorClick( @@ -341,11 +353,13 @@ class TakerBloc extends Bloc { return; } - emit(state.copyWith( - showOrderSelector: () => !state.showOrderSelector, - showCoinSelector: () => false, - bestOrders: _haveBestOrders ? () => state.bestOrders : () => null, - )); + emit( + state.copyWith( + showOrderSelector: () => !state.showOrderSelector, + showCoinSelector: () => false, + bestOrders: _haveBestOrders ? () => state.bestOrders : () => null, + ), + ); if (state.showOrderSelector && !_haveBestOrders) { add(TakerUpdateBestOrders()); @@ -362,29 +376,24 @@ class TakerBloc extends Bloc { TakerCoinSelectorOpen event, Emitter emit, ) { - emit(state.copyWith( - showCoinSelector: () => event.isOpen, - )); + emit(state.copyWith(showCoinSelector: () => event.isOpen)); } void _onOrderSelectorOpen( TakerOrderSelectorOpen event, Emitter emit, ) { - emit(state.copyWith( - showOrderSelector: () => event.isOpen, - )); + emit(state.copyWith(showOrderSelector: () => event.isOpen)); } - void _onClear( - TakerClear event, - Emitter emit, - ) { + void _onClear(TakerClear event, Emitter emit) { _maxSellAmountTimer?.cancel(); - emit(TakerState.initial().copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable, - )); + emit( + TakerState.initial().copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + ), + ); } void _subscribeMaxSellAmount() { @@ -406,44 +415,59 @@ class TakerBloc extends Bloc { } if (state.availableBalanceState == AvailableBalanceState.initial || event.setLoadingStatus) { - emitter(state.copyWith( - availableBalanceState: () => AvailableBalanceState.loading)); + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading, + ), + ); } - // Required here because of the manual RPC calls that bypass the sdk + // Required here because of the manual RPC calls that bypass the sdk final activeAssets = await _sdk.assets.getActivatedAssets(); - final isAssetActive = - activeAssets.any((asset) => asset.id == state.sellCoin!.id); + final isAssetActive = activeAssets.any( + (asset) => asset.id == state.sellCoin!.id, + ); if (!isAssetActive) { - // Intentionally leave the state as loading so that a spinner is shown + // Intentionally leave the state as loading so that a spinner is shown // instead of a "0.00" balance hinting that the asset is active when it // is not. if (state.availableBalanceState != AvailableBalanceState.loading) { - emitter(state.copyWith( - availableBalanceState: () => AvailableBalanceState.loading)); + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading, + ), + ); } - return; + return; } if (!_isLoggedIn) { - emitter(state.copyWith( - availableBalanceState: () => AvailableBalanceState.unavailable)); + emitter( + state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + ), + ); } else { - Rational? maxSellAmount = - await _dexRepo.getMaxTakerVolume(state.sellCoin!.abbr); + Rational? maxSellAmount = await _dexRepo.getMaxTakerVolume( + state.sellCoin!.abbr, + ); if (maxSellAmount != null) { - emitter(state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: () => AvailableBalanceState.success, - )); + emitter( + state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: () => AvailableBalanceState.success, + ), + ); } else { maxSellAmount = await _frequentlyGetMaxTakerVolume(); - emitter(state.copyWith( - maxSellAmount: () => maxSellAmount, - availableBalanceState: maxSellAmount == null - ? () => AvailableBalanceState.failure - : () => AvailableBalanceState.success, - )); + emitter( + state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: maxSellAmount == null + ? () => AvailableBalanceState.failure + : () => AvailableBalanceState.success, + ), + ); } } } @@ -472,27 +496,22 @@ class TakerBloc extends Bloc { ) async { if (state.sellCoin == null) return; if (!_isLoggedIn) { - emit(state.copyWith( - minSellAmount: () => null, - )); + emit(state.copyWith(minSellAmount: () => null)); return; } - final Rational? minSellAmount = - await _dexRepo.getMinTradingVolume(state.sellCoin!.abbr); + final Rational? minSellAmount = await _dexRepo.getMinTradingVolume( + state.sellCoin!.abbr, + ); - emit(state.copyWith( - minSellAmount: () => minSellAmount, - )); + emit(state.copyWith(minSellAmount: () => minSellAmount)); } Future _onUpdateFees( TakerUpdateFees event, Emitter emit, ) async { - emit(state.copyWith( - tradePreimage: () => null, - )); + emit(state.copyWith(tradePreimage: () => null)); if (!_validator.canRequestPreimage) return; @@ -500,10 +519,7 @@ class TakerBloc extends Bloc { add(TakerSetPreimage(preimageData.data)); } - void _onSetPreimage( - TakerSetPreimage event, - Emitter emit, - ) { + void _onSetPreimage(TakerSetPreimage event, Emitter emit) { emit(state.copyWith(tradePreimage: () => event.tradePreimage)); } @@ -517,8 +533,12 @@ class TakerBloc extends Bloc { state.sellAmount, ); } catch (e, s) { - log(e.toString(), - trace: s, path: 'taker_bloc::_getFeesData', isError: true); + log( + e.toString(), + trace: s, + path: 'taker_bloc::_getFeesData', + isError: true, + ); return DataFromService(error: TextError(error: 'Failed to request fees')); } } @@ -527,8 +547,10 @@ class TakerBloc extends Bloc { if (abbr == null || !_isLoggedIn) return; _activatingAssets = true; - final List activationErrors = - await activateCoinIfNeeded(abbr, _coinsRepo); + final List activationErrors = await activateCoinIfNeeded( + abbr, + _coinsRepo, + ); _activatingAssets = false; if (activationErrors.isNotEmpty) { @@ -536,19 +558,11 @@ class TakerBloc extends Bloc { } } - void _onSetInProgress( - TakerSetInProgress event, - Emitter emit, - ) { - emit(state.copyWith( - inProgress: () => event.value, - )); + void _onSetInProgress(TakerSetInProgress event, Emitter emit) { + emit(state.copyWith(inProgress: () => event.value)); } - void _onSetWalletReady( - TakerSetWalletIsReady event, - Emitter _, - ) { + void _onSetWalletReady(TakerSetWalletIsReady event, Emitter _) { _waitingForWallet = !event.ready; } @@ -560,10 +574,7 @@ class TakerBloc extends Bloc { } Future _onReInit(TakerReInit event, Emitter emit) async { - emit(state.copyWith( - errors: () => [], - autovalidate: () => false, - )); + emit(state.copyWith(errors: () => [], autovalidate: () => false)); await _autoActivateCoin(state.sellCoin?.abbr); await _autoActivateCoin(state.selectedOrder?.coin); } diff --git a/lib/blocs/orderbook_bloc.dart b/lib/blocs/orderbook_bloc.dart index 2af564dd90..d06174ed88 100644 --- a/lib/blocs/orderbook_bloc.dart +++ b/lib/blocs/orderbook_bloc.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' - show GeneralErrorResponse, OrderbookResponse; + show OrderbookResponse; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/blocs/bloc_base.dart'; diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart index 2a9b65da9b..605bab4f7b 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_activation_status_bar.dart @@ -154,6 +154,11 @@ class _ZhtlcActivationStatusBarState extends State { ), ), ), + ], + ), + Row( + children: [ + const SizedBox(width: 26), Expanded( child: AutoScrollText( text: LocaleKeys.zhtlcActivationWarning.tr(), diff --git a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart index adcf79f8f2..881c3e0f4f 100644 --- a/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart +++ b/lib/views/wallet/wallet_page/common/zhtlc/zhtlc_configuration_dialog.dart @@ -103,12 +103,13 @@ class _ZhtlcConfigurationDialogState extends State { super.initState(); // On web, use './zcash-params' as default, otherwise use prefilledZcashPath + // TODO: get from config factory constructor, or move to constants final defaultZcashPath = kIsWeb ? './zcash-params' : widget.prefilledZcashPath; zcashPathController = TextEditingController(text: defaultZcashPath); blocksPerIterController = TextEditingController(text: '1000'); - intervalMsController = TextEditingController(text: '0'); + intervalMsController = TextEditingController(text: '200'); _subscribeToAuthChanges(); } From 1e58d9114964d7e697e1b165dea961791d4aed1a Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 1 Oct 2025 15:34:44 +0200 Subject: [PATCH 22/24] fix(zhtlc): track ongoing activations to prevent duplicate requests --- .../coins_manager/coins_manager_bloc.dart | 2 +- .../arrr_activation_service.dart | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart index 9f3bc72d0c..393d92631c 100644 --- a/lib/bloc/coins_manager/coins_manager_bloc.dart +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -67,7 +67,7 @@ class CoinsManagerBloc extends Bloc { ) async { final List filters = []; - final mergedCoinsList = mergeCoinLists( + final mergedCoinsList = _mergeCoinLists( await _getOriginalCoinList( _coinsRepo, event.action, diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index bf78158d94..06040bb97a 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -27,6 +27,9 @@ class ArrrActivationService { /// Completer to wait for configuration when needed final Map> _configCompleters = {}; + /// Track ongoing activation flows per asset to prevent duplicate runs + final Map> _ongoingActivations = {}; + /// Subscription to auth state changes StreamSubscription? _authSubscription; @@ -42,10 +45,34 @@ class ArrrActivationService { Future activateArrr( Asset asset, { ZhtlcUserConfig? initialConfig, - }) async { + }) { if (_isDisposing || _configRequestController.isClosed) { throw StateError('ArrrActivationService has been disposed'); } + + final existingActivation = _ongoingActivations[asset.id]; + if (existingActivation != null) { + _log.info( + 'Activation already in progress for ${asset.id.id} - reusing existing future', + ); + return existingActivation; + } + + late Future activationFuture; + activationFuture = + _activateArrrInternal(asset, initialConfig: initialConfig).whenComplete( + () { + _ongoingActivations.remove(asset.id); + }, + ); + _ongoingActivations[asset.id] = activationFuture; + return activationFuture; + } + + Future _activateArrrInternal( + Asset asset, { + ZhtlcUserConfig? initialConfig, + }) async { var config = initialConfig ?? await _getOrRequestConfiguration(asset.id); if (config == null) { From 066dd1dd3c8864983b1c3696f02baf3587ff434f Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 2 Oct 2025 14:48:39 +0200 Subject: [PATCH 23/24] fix(taker-validator): revert to getting active assets from API getWalletAssets was used to improve form validation speed to fix hitching and lengthy delays, but it is not correct and allowed for form submissions with inactive coins, resulting in unexpected error messages --- assets/translations/en.json | 1 + lib/bloc/bridge_form/bridge_validator.dart | 2 +- lib/bloc/taker_form/taker_bloc.dart | 1 - lib/bloc/taker_form/taker_validator.dart | 7 +++---- lib/generated/codegen_loader.g.dart | 1 + lib/services/arrr_activation/arrr_activation_service.dart | 1 - 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 8af4fda638..d73ae0e965 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -510,6 +510,7 @@ "userActionRequired": "User action required", "unknown": "Unknown", "unableToActiveCoin": "Unable to activate {}", + "coinIsNotActive": "{} is not active", "feedback": "Feedback", "feedbackViewTitle": "Send us your feedback", "feedbackPageDescription": "Help us improve by sharing your suggestions, reporting bugs, or giving general feedback.", diff --git a/lib/bloc/bridge_form/bridge_validator.dart b/lib/bloc/bridge_form/bridge_validator.dart index 189da19abf..0e5c59d1b1 100644 --- a/lib/bloc/bridge_form/bridge_validator.dart +++ b/lib/bloc/bridge_form/bridge_validator.dart @@ -292,7 +292,7 @@ class BridgeValidator { } DexFormError _coinNotActiveError(String abbr) { - return DexFormError(error: '$abbr is not active.'); + return DexFormError(error: LocaleKeys.coinIsNotActive.tr(args: [abbr])); } DexFormError _selectSourceProtocolError() => diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index fdabd2e888..cdba57c9a6 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math' show min; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart index 8ec8e3a7a7..e636fd59fc 100644 --- a/lib/bloc/taker_form/taker_validator.dart +++ b/lib/bloc/taker_form/taker_validator.dart @@ -15,7 +15,6 @@ import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dar import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/data_from_service.dart'; import 'package:web_dex/model/dex_form_error.dart'; -import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/shared/utils/formatters.dart'; @@ -33,7 +32,7 @@ class TakerValidator { _coinsRepo = coinsRepo, _dexRepo = dexRepo, _sdk = sdk, - add = bloc.add; + add = bloc.add; final TakerBloc _bloc; final CoinsRepo _coinsRepo; @@ -222,7 +221,7 @@ class TakerValidator { Future _validateCoinAndParent(String abbr) async { final coin = _sdk.getSdkAsset(abbr); - final enabledAssets = await _sdk.getWalletAssets(); + final enabledAssets = await _sdk.assets.getActivatedAssets(); final isAssetEnabled = enabledAssets.contains(coin); final parentId = coin.id.parentId; final parent = _sdk.assets.available[parentId]; @@ -317,7 +316,7 @@ class TakerValidator { } DexFormError _coinNotActiveError(String abbr) { - return DexFormError(error: '$abbr is not active.'); + return DexFormError(error: LocaleKeys.coinIsNotActive.tr(args: [abbr])); } DexFormError _selectSellCoinError() => diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 5e302bac37..a34c75bce0 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -507,6 +507,7 @@ abstract class LocaleKeys { static const userActionRequired = 'userActionRequired'; static const unknown = 'unknown'; static const unableToActiveCoin = 'unableToActiveCoin'; + static const coinIsNotActive = 'coinIsNotActive'; static const feedback = 'feedback'; static const feedbackViewTitle = 'feedbackViewTitle'; static const feedbackPageDescription = 'feedbackPageDescription'; diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index 06040bb97a..a843b14011 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -193,7 +193,6 @@ class ArrrActivationService { initialDelay: const Duration(seconds: 5), maxDelay: const Duration(seconds: 30), ), - shouldRetry: (error) => error is _RetryableZhtlcActivationException, onRetry: (currentAttempt, error, delay) { _log.warning( 'ARRR activation attempt $currentAttempt for ${asset.id.id} failed. ' From 4416b1c9a0940dfb1b7f87d97c740a0ebfe2fe94 Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 2 Oct 2025 20:41:31 +0200 Subject: [PATCH 24/24] chore(sdk): switch back to dev branch (sdk branch merged) --- .gitmodules | 2 +- sdk | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index cb4eb4a2a4..ad07a1ea79 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "sdk"] path = sdk url = https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git - branch = bugfix/zhltc-activation-fixes + branch = dev update = checkout fetchRecurseSubmodules = on-demand ignore = dirty diff --git a/sdk b/sdk index 1af4278417..b76012e0e6 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 1af427841768c5350e018a1b3cf4534898da3fde +Subproject commit b76012e0e6c8b4db83320d8c710eebf884e00721