diff --git a/assets/translations/en.json b/assets/translations/en.json index 7b6f664a72..2f145fe3f7 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -484,12 +484,18 @@ "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", + "withdrawDestination": "Destination", + "withdrawNetworkDetails": "Network details", + "withdrawHighFee": "High fee", + "withdrawPreviewExpiresIn": "Preview expires in {}s", + "withdrawPreviewRefreshing": "Refreshing preview...", "withdrawTronBandwidthUsed": "Bandwidth (NET) used", "withdrawTronBandwidthFee": "Bandwidth fee", "withdrawTronBandwidthSource": "Bandwidth source", "withdrawTronEnergyUsed": "Energy used", "withdrawTronEnergyFee": "Energy fee", "withdrawTronEnergySource": "Energy source", + "withdrawTronAccountActivationFee": "Account activation fee", "withdrawTronFeeSummary": "TRON fee summary", "withdrawTronFeePaidIn": "Paid in {}", "withdrawTronBandwidthCovered": "Covered by free NET bandwidth", @@ -497,6 +503,10 @@ "withdrawTronResourceNotUsed": "Not used", "withdrawTronFeeSummaryCharged": "Network will charge {} {}.", "withdrawTronFeeSummaryCovered": "No {} fee will be charged (covered by resources).", + "withdrawTronPreviewExpired": "This TRON transaction preview expired. Regenerate it to continue.", + "withdrawTronPreviewRefreshFailed": "This TRON transaction preview expired and could not be refreshed.", + "withdrawTronPreviewRegenerate": "Regenerate", + "withdrawAwaitingConfirmations": "Awaiting confirmations", "txHistoryFetchError": "Error fetching tx history from the endpoint. Unsupported type: {}", "txHistoryNoTransactions": "Transactions are not available", "maxGapLimitReached": "Maximum gap limit reached - please use existing unused addresses first", diff --git a/lib/bloc/bridge_form/bridge_bloc.dart b/lib/bloc/bridge_form/bridge_bloc.dart index 354dee5825..4e93227d36 100644 --- a/lib/bloc/bridge_form/bridge_bloc.dart +++ b/lib/bloc/bridge_form/bridge_bloc.dart @@ -26,6 +26,7 @@ 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/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/model/typedef.dart'; import 'package:web_dex/model/wallet.dart'; @@ -672,7 +673,12 @@ class BridgeBloc extends Bloc { path: 'bridge_bloc::_getFeesData', isError: true, ); - return DataFromService(error: TextError(error: 'Failed to request fees')); + return DataFromService( + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), + ); } } diff --git a/lib/bloc/bridge_form/bridge_validator.dart b/lib/bloc/bridge_form/bridge_validator.dart index 66876c45de..d7290bf15a 100644 --- a/lib/bloc/bridge_form/bridge_validator.dart +++ b/lib/bloc/bridge_form/bridge_validator.dart @@ -16,6 +16,7 @@ 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/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -139,7 +140,10 @@ class BridgeValidator { isError: true, ); return DataFromService( - error: TextError(error: 'Failed to request trade preimage'), + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), ); } } 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 495526d577..93aa64c2c7 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 @@ -14,6 +14,7 @@ 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/constants.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -354,7 +355,10 @@ class PortfolioGrowthBloc ); emit( GrowthChartLoadFailure( - error: TextError(error: 'Failed to load portfolio growth'), + error: TextError( + error: formatKdfUserFacingError(error), + technicalDetails: extractKdfTechnicalDetails(error), + ), selectedPeriod: event.selectedPeriod, totalCoins: totalCoins, coinsWithKnownBalance: coinsWithKnownBalance, 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 31b8245a97..164ba41ef4 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 @@ -15,6 +15,7 @@ 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/constants.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; part 'profit_loss_event.dart'; part 'profit_loss_state.dart'; @@ -291,7 +292,10 @@ class ProfitLossBloc extends Bloc { _log.shout('Failed to load portfolio profit/loss', error, stackTrace); emit( ProfitLossLoadFailure( - error: TextError(error: 'Failed to load portfolio profit/loss'), + error: TextError( + error: formatKdfUserFacingError(error), + technicalDetails: extractKdfTechnicalDetails(error), + ), selectedPeriod: event.selectedPeriod, ), ); diff --git a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart index c49501fe6d..9d9b4aa6ee 100644 --- a/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart +++ b/lib/bloc/coin_addresses/bloc/coin_addresses_bloc.dart @@ -228,37 +228,7 @@ class CoinAddressesBloc extends Bloc { return LocaleKeys.connectionToServersFailing.tr(args: [assetId]); } - if (error is SdkError) { - return _localizedSdkError(error); - } - - if (error is MmRpcException) { - return error.localizedMessage; - } - - if (error is GeneralErrorResponse) { - return error.localizedMessage; - } - - final raw = error.toString().trim(); - if (raw.isEmpty) { - return LocaleKeys.somethingWrong.tr(); - } - - const exceptionPrefix = 'Exception: '; - if (raw.startsWith(exceptionPrefix)) { - final message = raw.substring(exceptionPrefix.length).trim(); - if (message.isNotEmpty) { - return message; - } - } - - return raw; - } - - String _localizedSdkError(SdkError error) { - final localized = error.messageKey.tr(args: error.messageArgs); - return localized == error.messageKey ? error.fallbackMessage : localized; + return formatKdfUserFacingError(error); } bool _isNetworkLikeError(Object error) { diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index 504381b769..3637e33834 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -808,17 +808,38 @@ class CoinsRepo { _invalidateActivatedAssetsCache(); } - double? getUsdPriceByAmount(String amount, String coinAbbr) { + /// Calculates USD value for a numeric [amount] of [coinAbbr]. + /// + /// Prefer this method over [getUsdPriceByAmount] to avoid string parsing + /// issues (e.g. accidentally passing display-formatted values like + /// `"1.1 TRX"`). + double? getUsdPriceForAmount(num amount, String coinAbbr) { final Coin? coin = getCoin(coinAbbr); - final double? parsedAmount = double.tryParse(amount); + final double parsedAmount = amount.toDouble(); final double? usdPrice = coin?.usdPrice?.price?.toDouble(); - if (coin == null || usdPrice == null || parsedAmount == null) { + if (coin == null || usdPrice == null) { return null; } return parsedAmount * usdPrice; } + @Deprecated( + 'Use getUsdPriceForAmount(num amount, String coinAbbr) to avoid ' + 'string-parsing bugs from display-formatted values.', + ) + double? getUsdPriceByAmount(String amount, String coinAbbr) { + final double? parsedAmount = double.tryParse(amount); + if (parsedAmount == null) { + _log.warning( + 'Invalid amount "$amount" passed to getUsdPriceByAmount for $coinAbbr. ' + 'Use getUsdPriceForAmount() with a numeric value.', + ); + return null; + } + return getUsdPriceForAmount(parsedAmount, coinAbbr); + } + /// Fetches current prices for a broad set of assets /// /// This method is used to fetch prices for a broad set of assets so unauthenticated users diff --git a/lib/bloc/coins_bloc/coins_state.dart b/lib/bloc/coins_bloc/coins_state.dart index 542c2ed0f1..62266988b2 100644 --- a/lib/bloc/coins_bloc/coins_state.dart +++ b/lib/bloc/coins_bloc/coins_state.dart @@ -78,28 +78,34 @@ class CoinsState extends Equatable { return getPriceForAsset(assetId)?.change24h?.toDouble(); } - /// Calculates the USD price for a given amount of a coin - /// - /// [amount] The amount of the coin as a string - /// [coinAbbr] The abbreviation/symbol of the coin + /// Calculates the USD price for a given numeric [amount] of [coinAbbr]. /// /// Returns null if: /// - The coin is not found in the state - /// - The amount cannot be parsed to a double /// - The coin does not have a USD price /// /// Note: This will be migrated to use the SDK's price functionality in the future. /// See the MarketDataManager in the SDK for the new implementation. - @Deprecated('Use sdk.prices.fiatPrice(assetId) * amount instead') - double? getUsdPriceByAmount(String amount, String coinAbbr) { + double? getUsdPriceForAmount(num amount, String coinAbbr) { final Coin? coin = coins[coinAbbr]; - final double? parsedAmount = double.tryParse(amount); + final double parsedAmount = amount.toDouble(); final CexPrice? cexPrice = prices[coinAbbr.toUpperCase()]; final double? usdPrice = cexPrice?.price?.toDouble(); - if (coin == null || usdPrice == null || parsedAmount == null) { + if (coin == null || usdPrice == null) { return null; } return parsedAmount * usdPrice; } + + /// Backward-compatible string overload. + @Deprecated( + 'Use getUsdPriceForAmount(num amount, String coinAbbr) to avoid ' + 'string-parsing bugs from display-formatted values.', + ) + double? getUsdPriceByAmount(String amount, String coinAbbr) { + final double? parsedAmount = double.tryParse(amount); + if (parsedAmount == null) return null; + return getUsdPriceForAmount(parsedAmount, coinAbbr); + } } diff --git a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart index b724a25393..96abc96975 100644 --- a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart @@ -187,8 +187,8 @@ class CustomTokenImportBloc final balanceInfo = await _coinsRepo.tryGetBalanceInfo(tokenData.id); final balance = balanceInfo.spendable; - final usdBalance = _coinsRepo.getUsdPriceByAmount( - balance.toString(), + final usdBalance = _coinsRepo.getUsdPriceForAmount( + balance.toDouble(), tokenData.id.id, ); diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart index 8f1a679ed1..c2584e8041 100644 --- a/lib/bloc/taker_form/taker_bloc.dart +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -28,6 +28,7 @@ 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/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -608,7 +609,12 @@ class TakerBloc extends Bloc { path: 'taker_bloc::_getFeesData', isError: true, ); - return DataFromService(error: TextError(error: 'Failed to request fees')); + return DataFromService( + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), + ); } } diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart index d83b9f55c3..f6c3f82f08 100644 --- a/lib/bloc/taker_form/taker_validator.dart +++ b/lib/bloc/taker_form/taker_validator.dart @@ -16,6 +16,7 @@ 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/text_error.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/model/trade_preimage.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -333,7 +334,10 @@ class TakerValidator { isError: true, ); return DataFromService( - error: TextError(error: 'Failed to request trade preimage'), + error: TextError( + error: formatKdfUserFacingError(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), ); } } diff --git a/lib/bloc/transaction_history/transaction_history_bloc.dart b/lib/bloc/transaction_history/transaction_history_bloc.dart index ffeb31cdc8..e40b8484e2 100644 --- a/lib/bloc/transaction_history/transaction_history_bloc.dart +++ b/lib/bloc/transaction_history/transaction_history_bloc.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; -import 'package:komodo_defi_sdk/src/activation/activation_exceptions.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_event.dart'; import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; -import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/extensions/transaction_extensions.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/shared/utils/utils.dart'; class TransactionHistoryBloc @@ -28,26 +26,7 @@ class TransactionHistoryBloc final KomodoDefiSdk _sdk; StreamSubscription>? _historySubscription; - String _errorMessageFrom(Object error) { - if (error is SdkError) { - final localized = error.messageKey.tr(args: error.messageArgs); - return localized == error.messageKey ? error.fallbackMessage : localized; - } - - if (error is ActivationFailedException && error.originalError is SdkError) { - final sdkError = error.originalError as SdkError; - final localized = sdkError.messageKey.tr(args: sdkError.messageArgs); - return localized == sdkError.messageKey - ? sdkError.fallbackMessage - : localized; - } - - if (error is ActivationFailedException) { - return 'Asset activation failed: ${error.message}'; - } - - return LocaleKeys.somethingWrong.tr(); - } + String _errorMessageFrom(Object error) => formatKdfUserFacingError(error); @override Future close() async { diff --git a/lib/bloc/withdraw_form/withdraw_form_bloc.dart b/lib/bloc/withdraw_form/withdraw_form_bloc.dart index ba599f24de..d8928edd9a 100644 --- a/lib/bloc/withdraw_form/withdraw_form_bloc.dart +++ b/lib/bloc/withdraw_form/withdraw_form_bloc.dart @@ -1,6 +1,8 @@ +import 'dart:async'; + +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; @@ -26,8 +28,10 @@ class WithdrawFormBloc extends Bloc { static final Logger _logger = Logger('WithdrawFormBloc'); static const _unsupportedSiaHardwareWalletMessage = 'SIA is not supported for hardware wallets in this release.'; + final KomodoDefiSdk _sdk; final WalletType? _walletType; + Timer? _tronPreviewTimer; WithdrawFormBloc({ required Asset asset, @@ -44,7 +48,10 @@ class WithdrawFormBloc extends Bloc { amount: '0', ), ) { - on(_onRecipientChanged); + on( + _onRecipientChanged, + transformer: restartable(), + ); on(_onAmountChanged); on(_onSourceChanged); on(_onMaxAmountEnabled); @@ -54,8 +61,16 @@ class WithdrawFormBloc extends Bloc { on(_onMemoChanged); on(_onIbcTransferEnabled); on(_onIbcChannelChanged); - on(_onPreviewSubmitted); - on(_onSubmitted); + on( + _onPreviewSubmitted, + transformer: droppable(), + ); + on(_onSubmitted, transformer: droppable()); + on(_onTronPreviewTicked); + on( + _onTronPreviewRefreshRequested, + transformer: droppable(), + ); on(_onCancelled); on(_onReset); on(_onStepReverted); @@ -67,46 +82,167 @@ class WithdrawFormBloc extends Bloc { add(const WithdrawFormFeeOptionsRequested()); } - String _formatErrorMessage(Object error, {String? fallbackPrefix}) { - String resolvedMessage; + bool _isTronAsset(Asset asset) => + asset.protocol is TrxProtocol || asset.protocol is Trc20Protocol; + + void _cancelTronPreviewTimer() { + _tronPreviewTimer?.cancel(); + _tronPreviewTimer = null; + } - if (error is MmRpcException) { - resolvedMessage = error.localizedMessage; - } else if (error is GeneralErrorResponse) { - resolvedMessage = error.localizedMessage; - } else if (error is SdkError) { - final localized = error.messageKey.tr(args: error.messageArgs); - resolvedMessage = localized == error.messageKey - ? error.fallbackMessage - : localized; - } else { - resolvedMessage = error.toString(); + DateTime? _buildPreviewExpiryAt( + WithdrawFormState state, + WithdrawalPreview preview, + ) { + if (!_isTronAsset(state.asset)) { + return null; } - final message = _normalizeCommonErrors(resolvedMessage); - return fallbackPrefix == null ? message : '$fallbackPrefix: $message'; + return DateTime.fromMillisecondsSinceEpoch( + preview.timestamp * 1000, + isUtc: true, + ).add( + const Duration(seconds: WithdrawFormState.tronPreviewExpirationSeconds), + ); } - String _extractTechnicalDetails(Object error) { - if (error is SdkError) { - return error.fallbackMessage; + int _calculatePreviewSecondsRemaining(DateTime expiryAt) { + final remainingMs = expiryAt + .difference(DateTime.now().toUtc()) + .inMilliseconds; + if (remainingMs <= 0) { + return 0; } - if (error is MmRpcException) { - return error.message ?? error.toString(); + + return (remainingMs / 1000).ceil(); + } + + void _startTronPreviewTimer(WithdrawFormState state) { + _cancelTronPreviewTimer(); + + if (!_isTronAsset(state.asset) || + state.step != WithdrawFormStep.confirm || + state.preview == null || + state.previewExpiresAt == null || + state.isPreviewExpired) { + return; } - if (error is GeneralErrorResponse) { - return error.error ?? error.toString(); + + _tronPreviewTimer = Timer.periodic(const Duration(seconds: 1), (_) { + add(const WithdrawFormTronPreviewTicked()); + }); + } + + TextError? _previewGuardError() { + if (_isUnsupportedSiaHardwareWalletFlow) { + return TextError(error: _unsupportedSiaHardwareWalletMessage); } - if (error is WithdrawalException) { - return error.message; + + if (_isSelfTransfer) { + return TextError(error: LocaleKeys.cannotSendToSelf.tr()); } - return error.toString(); + + return null; } - TextError _buildTextError(Object error, {String? fallbackPrefix}) { + Future _generatePreview( + WithdrawFormState requestState, + ) async { + final params = requestState.toWithdrawParameters(); + return _sdk.withdrawals.previewWithdrawal(params); + } + + bool _matchesPreviewRequest( + WithdrawFormState requestState, + WithdrawFormState currentState, + ) { + final requestParams = requestState.toWithdrawParameters(); + final currentParams = currentState.toWithdrawParameters(); + if (requestParams == currentParams) { + return true; + } + + if (_isBackgroundFeePriorityDefault(requestState, currentState)) { + final requestWithDefaultFeePriority = requestState.copyWith( + selectedFeePriority: () => currentState.selectedFeePriority, + ); + return requestWithDefaultFeePriority.toWithdrawParameters() == + currentParams; + } + + return false; + } + + bool _isBackgroundFeePriorityDefault( + WithdrawFormState requestState, + WithdrawFormState currentState, + ) { + return !requestState.isCustomFee && + !currentState.isCustomFee && + requestState.selectedFeePriority == null && + currentState.selectedFeePriority == WithdrawalFeeLevel.medium && + currentState.feeOptions != null; + } + + void _emitPreviewState( + Emitter emit, + WithdrawFormState requestState, + WithdrawalPreview preview, { + required bool moveToConfirm, + }) { + final currentState = state; + if (!_matchesPreviewRequest(requestState, currentState)) { + emit( + currentState.copyWith( + isSending: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + _cancelTronPreviewTimer(); + return; + } + + final expiryAt = _buildPreviewExpiryAt(currentState, preview); + final secondsRemaining = expiryAt == null + ? null + : _calculatePreviewSecondsRemaining(expiryAt); + final isExpired = secondsRemaining != null && secondsRemaining <= 0; + final nextState = currentState.copyWith( + preview: () => preview, + step: moveToConfirm ? WithdrawFormStep.confirm : currentState.step, + previewError: () => null, + transactionError: () => null, + confirmStepError: () => isExpired + ? TextError(error: LocaleKeys.withdrawTronPreviewExpired.tr()) + : null, + isSending: false, + isPreviewRefreshing: false, + isPreviewExpired: isExpired, + previewExpiresAt: () => expiryAt, + previewSecondsRemaining: () => secondsRemaining, + isAwaitingTrezorConfirmation: false, + ); + + emit(nextState); + + if (isExpired) { + _cancelTronPreviewTimer(); + return; + } + + _startTronPreviewTimer(nextState); + } + + String _formatErrorMessage(Object error) { + final resolved = formatKdfUserFacingError(error); + return _normalizeCommonErrors(resolved); + } + + TextError _buildTextError(Object error) { return TextError( - error: _formatErrorMessage(error, fallbackPrefix: fallbackPrefix), - technicalDetails: _extractTechnicalDetails(error), + error: _formatErrorMessage(error), + technicalDetails: extractKdfTechnicalDetails(error), ); } @@ -187,7 +323,10 @@ class WithdrawFormBloc extends Bloc { } catch (e) { emit( state.copyWith( - networkError: () => TextError(error: 'Failed to load addresses: $e'), + networkError: () => TextError( + error: _formatErrorMessage(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), ), ); } @@ -232,6 +371,8 @@ class WithdrawFormBloc extends Bloc { WithdrawFormRecipientChanged event, Emitter emit, ) async { + if (state.isSending || state.step != WithdrawFormStep.fill) return; + try { final trimmedAddress = event.address.trim(); @@ -262,6 +403,11 @@ class WithdrawFormBloc extends Bloc { asset: state.asset, address: result.convertedAddress, ); + if (state.isSending || + state.step != WithdrawFormStep.fill || + state.recipientAddress != trimmedAddress) { + return; + } final isMixedCaseAdddress = result.convertedAddress != trimmedAddress; if (validationResult.isValid) { @@ -284,6 +430,11 @@ class WithdrawFormBloc extends Bloc { asset: state.asset, address: trimmedAddress, ); + if (state.isSending || + state.step != WithdrawFormStep.fill || + state.recipientAddress != trimmedAddress) { + return; + } if (!validationResult.isValid) { emit( state.copyWith( @@ -308,8 +459,10 @@ class WithdrawFormBloc extends Bloc { emit( state.copyWith( recipientAddress: event.address.trim(), - recipientAddressError: () => - TextError(error: 'Address validation failed: $e'), + recipientAddressError: () => TextError( + error: _formatErrorMessage(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), isMixedCaseAddress: false, ), ); @@ -325,6 +478,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormAmountChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; if (state.isMaxAmount) return; try { @@ -376,6 +530,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormSourceChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; final balance = event.address.balance; final updatedAmount = state.isMaxAmount ? balance.spendable.toString() @@ -401,6 +556,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormMaxAmountEnabled event, Emitter emit, ) async { + if (state.isSending || state.step != WithdrawFormStep.fill) return; if (event.isEnabled && state.asset.id.parentId != null) { final parentId = state.asset.id.parentId!; final parentBalance = @@ -439,6 +595,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormCustomFeeEnabled event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; final defaultPriority = state.selectedFeePriority ?? (state.feeOptions != null ? WithdrawalFeeLevel.medium : null); @@ -457,6 +614,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormCustomFeeChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; try { _validateFee(event.fee); emit( @@ -473,6 +631,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormFeePriorityChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; emit( state.copyWith( selectedFeePriority: () => event.priority, @@ -576,6 +735,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormMemoChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; emit(state.copyWith(memo: () => event.memo)); } @@ -583,6 +743,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormIbcTransferEnabled event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; emit( state.copyWith( isIbcTransfer: event.isEnabled, @@ -596,6 +757,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormIbcChannelChanged event, Emitter emit, ) { + if (state.isSending || state.step != WithdrawFormStep.fill) return; if (event.channel.isEmpty) { emit( state.copyWith( @@ -619,12 +781,13 @@ class WithdrawFormBloc extends Bloc { WithdrawFormPreviewSubmitted event, Emitter emit, ) async { - if (state.hasValidationErrors) return; - if (_isUnsupportedSiaHardwareWalletFlow) { + final requestState = state; + if (requestState.hasValidationErrors) return; + final guardError = _previewGuardError(); + if (guardError != null) { emit( - state.copyWith( - previewError: () => - TextError(error: _unsupportedSiaHardwareWalletMessage), + requestState.copyWith( + previewError: () => guardError, isSending: false, isAwaitingTrezorConfirmation: false, ), @@ -632,43 +795,27 @@ class WithdrawFormBloc extends Bloc { return; } - if (_isSelfTransfer) { - emit( - state.copyWith( - previewError: () => - TextError(error: LocaleKeys.cannotSendToSelf.tr()), - isSending: false, - ), - ); - return; - } - try { + _cancelTronPreviewTimer(); + emit( - state.copyWith( + requestState.copyWith( isSending: true, previewError: () => null, - isAwaitingTrezorConfirmation: false, + confirmStepError: () => null, + isPreviewRefreshing: false, + isPreviewExpired: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isAwaitingTrezorConfirmation: _walletType == WalletType.trezor, ), ); - // For Trezor wallets, the preview generation might require user interaction - if (_walletType == WalletType.trezor) { - emit(state.copyWith(isAwaitingTrezorConfirmation: true)); - } - - final params = state.toWithdrawParameters(); - final preview = await _sdk.withdrawals.previewWithdrawal(params); - - emit( - state.copyWith( - preview: () => preview, - step: WithdrawFormStep.confirm, - isSending: false, - isAwaitingTrezorConfirmation: false, - ), - ); + final preview = await _generatePreview(requestState); + _emitPreviewState(emit, requestState, preview, moveToConfirm: true); } catch (e) { + _cancelTronPreviewTimer(); + // Capture FD snapshot when KDF withdrawal preview fails if (PlatformTuner.isIOS) { try { @@ -682,11 +829,130 @@ class WithdrawFormBloc extends Bloc { } } + if (!_matchesPreviewRequest(requestState, state)) { + emit( + state.copyWith( + isSending: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } + emit( state.copyWith( - previewError: () => - _buildTextError(e, fallbackPrefix: 'Failed to generate preview'), + previewError: () => _buildTextError(e), isSending: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + } + } + + void _onTronPreviewTicked( + WithdrawFormTronPreviewTicked event, + Emitter emit, + ) { + if (!_isTronAsset(state.asset) || + state.step != WithdrawFormStep.confirm || + state.preview == null) { + _cancelTronPreviewTimer(); + return; + } + + final expiryAt = state.previewExpiresAt; + if (expiryAt == null) { + _cancelTronPreviewTimer(); + return; + } + + final secondsRemaining = _calculatePreviewSecondsRemaining(expiryAt); + if (secondsRemaining > 0) { + if (secondsRemaining != state.previewSecondsRemaining) { + emit( + state.copyWith( + previewSecondsRemaining: () => secondsRemaining, + isPreviewExpired: false, + ), + ); + } + return; + } + + _cancelTronPreviewTimer(); + if (state.isPreviewRefreshing) { + return; + } + + emit( + state.copyWith(previewSecondsRemaining: () => 0, isPreviewExpired: true), + ); + add(const WithdrawFormTronPreviewRefreshRequested(isAutomatic: true)); + } + + Future _onTronPreviewRefreshRequested( + WithdrawFormTronPreviewRefreshRequested event, + Emitter emit, + ) async { + final requestState = state; + if (!_isTronAsset(requestState.asset) || + requestState.step != WithdrawFormStep.confirm || + requestState.preview == null || + requestState.isSending || + requestState.isPreviewRefreshing) { + return; + } + + final guardError = _previewGuardError(); + if (guardError != null) { + emit( + requestState.copyWith( + isPreviewRefreshing: false, + isPreviewExpired: true, + previewSecondsRemaining: () => 0, + confirmStepError: () => guardError, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } + + try { + _cancelTronPreviewTimer(); + + emit( + requestState.copyWith( + isPreviewRefreshing: true, + isPreviewExpired: true, + previewSecondsRemaining: () => 0, + confirmStepError: () => null, + transactionError: () => null, + isAwaitingTrezorConfirmation: _walletType == WalletType.trezor, + ), + ); + + final preview = await _generatePreview(requestState); + _emitPreviewState(emit, requestState, preview, moveToConfirm: false); + } catch (e) { + if (!_matchesPreviewRequest(requestState, state)) { + emit( + state.copyWith( + isSending: false, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } + + emit( + state.copyWith( + isPreviewRefreshing: false, + isPreviewExpired: true, + previewSecondsRemaining: () => 0, + confirmStepError: () => _buildTextError(e), isAwaitingTrezorConfirmation: false, ), ); @@ -710,11 +976,30 @@ class WithdrawFormBloc extends Bloc { return; } + if (_isTronAsset(state.asset) && + (state.isPreviewRefreshing || + state.isPreviewExpired || + state.previewSecondsRemaining == null || + state.previewSecondsRemaining == 0 || + state.hasConfirmStepError)) { + emit( + state.copyWith( + confirmStepError: () => + TextError(error: LocaleKeys.withdrawTronPreviewExpired.tr()), + isSending: false, + ), + ); + return; + } + try { + _cancelTronPreviewTimer(); + emit( state.copyWith( isSending: true, transactionError: () => null, + confirmStepError: () => null, // No second device interaction is needed on confirm isAwaitingTrezorConfirmation: false, ), @@ -764,11 +1049,17 @@ class WithdrawFormBloc extends Bloc { // Clear cached preview after successful broadcast preview: () => null, isSending: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isPreviewExpired: false, + isPreviewRefreshing: false, isAwaitingTrezorConfirmation: false, ), ); return; } catch (e) { + _cancelTronPreviewTimer(); + // Capture FD snapshot when KDF withdrawal submission fails if (PlatformTuner.isIOS) { try { @@ -784,10 +1075,10 @@ class WithdrawFormBloc extends Bloc { emit( state.copyWith( - transactionError: () => - _buildTextError(e, fallbackPrefix: 'Transaction failed'), + transactionError: () => _buildTextError(e), step: WithdrawFormStep.failed, isSending: false, + isPreviewRefreshing: false, isAwaitingTrezorConfirmation: false, ), ); @@ -814,6 +1105,7 @@ class WithdrawFormBloc extends Bloc { } void _onReset(WithdrawFormReset event, Emitter emit) { + _cancelTronPreviewTimer(); emit( WithdrawFormState( asset: state.asset, @@ -830,14 +1122,24 @@ class WithdrawFormBloc extends Bloc { WithdrawFormStepReverted event, Emitter emit, ) { + if (state.isSending || state.isPreviewRefreshing) { + return; + } + if (state.step == WithdrawFormStep.confirm) { + _cancelTronPreviewTimer(); emit( state.copyWith( step: WithdrawFormStep.fill, preview: () => null, previewError: () => null, transactionError: () => null, + confirmStepError: () => null, isSending: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isPreviewExpired: false, + isPreviewRefreshing: false, isAwaitingTrezorConfirmation: false, ), ); @@ -850,11 +1152,47 @@ class WithdrawFormBloc extends Bloc { ? WithdrawFormStep.confirm : WithdrawFormStep.fill; + if (nextStep == WithdrawFormStep.confirm && + _isTronAsset(state.asset) && + state.preview != null) { + final expiryAt = _buildPreviewExpiryAt(state, state.preview!); + final secondsRemaining = expiryAt == null + ? null + : _calculatePreviewSecondsRemaining(expiryAt); + final isExpired = secondsRemaining != null && secondsRemaining <= 0; + + final nextState = state.copyWith( + step: nextStep, + transactionError: () => null, + confirmStepError: () => isExpired + ? TextError(error: LocaleKeys.withdrawTronPreviewExpired.tr()) + : null, + isSending: false, + previewExpiresAt: () => expiryAt, + previewSecondsRemaining: () => secondsRemaining, + isPreviewExpired: isExpired, + isPreviewRefreshing: false, + isAwaitingTrezorConfirmation: false, + ); + emit(nextState); + + if (!isExpired) { + _startTronPreviewTimer(nextState); + } + return; + } + + _cancelTronPreviewTimer(); emit( state.copyWith( step: nextStep, transactionError: () => null, + confirmStepError: () => null, isSending: false, + previewExpiresAt: () => null, + previewSecondsRemaining: () => null, + isPreviewExpired: false, + isPreviewRefreshing: false, isAwaitingTrezorConfirmation: false, ), ); @@ -871,6 +1209,7 @@ class WithdrawFormBloc extends Bloc { WithdrawFormConvertAddressRequested event, Emitter emit, ) async { + if (state.isSending || state.step != WithdrawFormStep.fill) return; if (state.isMixedCaseAddress) return; try { @@ -894,8 +1233,10 @@ class WithdrawFormBloc extends Bloc { } catch (e) { emit( state.copyWith( - recipientAddressError: () => - TextError(error: 'Failed to convert address: $e'), + recipientAddressError: () => TextError( + error: _formatErrorMessage(e), + technicalDetails: extractKdfTechnicalDetails(e), + ), isSending: false, ), ); @@ -907,6 +1248,12 @@ class WithdrawFormBloc extends Bloc { final scale = Decimal.parse('1${'0' * decimals}'); return (Decimal.fromInt(amount) / scale).toDecimal(); } + + @override + Future close() { + _cancelTronPreviewTimer(); + return super.close(); + } } class MixedCaseAddressError extends BaseError { diff --git a/lib/bloc/withdraw_form/withdraw_form_event.dart b/lib/bloc/withdraw_form/withdraw_form_event.dart index aec3a92af9..f0a5ae4038 100644 --- a/lib/bloc/withdraw_form/withdraw_form_event.dart +++ b/lib/bloc/withdraw_form/withdraw_form_event.dart @@ -52,6 +52,16 @@ class WithdrawFormSubmitted extends WithdrawFormEvent { const WithdrawFormSubmitted(); } +class WithdrawFormTronPreviewTicked extends WithdrawFormEvent { + const WithdrawFormTronPreviewTicked(); +} + +class WithdrawFormTronPreviewRefreshRequested extends WithdrawFormEvent { + final bool isAutomatic; + + const WithdrawFormTronPreviewRefreshRequested({this.isAutomatic = false}); +} + class WithdrawFormCancelled extends WithdrawFormEvent { const WithdrawFormCancelled(); } diff --git a/lib/bloc/withdraw_form/withdraw_form_state.dart b/lib/bloc/withdraw_form/withdraw_form_state.dart index e9c378cf82..59b1191d75 100644 --- a/lib/bloc/withdraw_form/withdraw_form_state.dart +++ b/lib/bloc/withdraw_form/withdraw_form_state.dart @@ -8,6 +8,8 @@ import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/shared/utils/formatters.dart'; class WithdrawFormState extends Equatable { + static const int tronPreviewExpirationSeconds = 60; + final Asset asset; final AssetPubkeys? pubkeys; final WithdrawFormStep step; @@ -43,8 +45,16 @@ class WithdrawFormState extends Equatable { // Network/Transaction errors final TextError? previewError; // Errors during preview generation final TextError? transactionError; // Errors during transaction submission + final TextError? + confirmStepError; // Errors while refreshing an expired TRON preview final TextError? networkError; // Network connectivity errors + // TRON confirm preview lifetime + final DateTime? previewExpiresAt; + final int? previewSecondsRemaining; + final bool isPreviewExpired; + final bool isPreviewRefreshing; + bool get isCustomFeeSupported => asset.protocol is UtxoProtocol || asset.protocol is Erc20Protocol || @@ -56,8 +66,12 @@ class WithdrawFormState extends Equatable { asset.protocol is QtumProtocol || asset.protocol is TendermintProtocol; + bool get isTronAsset => + asset.protocol is TrxProtocol || asset.protocol is Trc20Protocol; + bool get hasPreviewError => previewError != null; bool get hasTransactionError => transactionError != null; + bool get hasConfirmStepError => confirmStepError != null; bool get hasAddressError => recipientAddressError != null; bool get hasValidationErrors => hasAddressError || @@ -133,7 +147,12 @@ class WithdrawFormState extends Equatable { this.ibcChannelError, this.previewError, this.transactionError, + this.confirmStepError, this.networkError, + this.previewExpiresAt, + this.previewSecondsRemaining, + this.isPreviewExpired = false, + this.isPreviewRefreshing = false, }); WithdrawFormState copyWith({ @@ -164,7 +183,12 @@ class WithdrawFormState extends Equatable { ValueGetter? ibcChannelError, ValueGetter? previewError, ValueGetter? transactionError, + ValueGetter? confirmStepError, ValueGetter? networkError, + ValueGetter? previewExpiresAt, + ValueGetter? previewSecondsRemaining, + bool? isPreviewExpired, + bool? isPreviewRefreshing, }) { return WithdrawFormState( asset: asset ?? this.asset, @@ -207,7 +231,18 @@ class WithdrawFormState extends Equatable { transactionError: transactionError != null ? transactionError() : this.transactionError, + confirmStepError: confirmStepError != null + ? confirmStepError() + : this.confirmStepError, networkError: networkError != null ? networkError() : this.networkError, + previewExpiresAt: previewExpiresAt != null + ? previewExpiresAt() + : this.previewExpiresAt, + previewSecondsRemaining: previewSecondsRemaining != null + ? previewSecondsRemaining() + : this.previewSecondsRemaining, + isPreviewExpired: isPreviewExpired ?? this.isPreviewExpired, + isPreviewRefreshing: isPreviewRefreshing ?? this.isPreviewRefreshing, ); } @@ -230,6 +265,7 @@ class WithdrawFormState extends Equatable { ibcSourceChannel: ibcChannel?.isNotEmpty == true ? int.tryParse(ibcChannel!.trim()) : null, + expirationSeconds: isTronAsset ? tronPreviewExpirationSeconds : null, isMax: isMaxAmount, ); } @@ -269,6 +305,11 @@ class WithdrawFormState extends Equatable { ibcChannelError, previewError, transactionError, + confirmStepError, networkError, + previewExpiresAt, + previewSecondsRemaining, + isPreviewExpired, + isPreviewRefreshing, ]; } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index f0a3ab40db..367f2ea6a1 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -521,12 +521,19 @@ abstract class LocaleKeys { static const withdrawPreview = 'withdrawPreview'; static const withdrawPreviewZhtlcNote = 'withdrawPreviewZhtlcNote'; static const withdrawPreviewError = 'withdrawPreviewError'; + static const withdrawDestination = 'withdrawDestination'; + static const withdrawNetworkDetails = 'withdrawNetworkDetails'; + static const withdrawHighFee = 'withdrawHighFee'; + static const withdrawPreviewExpiresIn = 'withdrawPreviewExpiresIn'; + static const withdrawPreviewRefreshing = 'withdrawPreviewRefreshing'; static const withdrawTronBandwidthUsed = 'withdrawTronBandwidthUsed'; static const withdrawTronBandwidthFee = 'withdrawTronBandwidthFee'; static const withdrawTronBandwidthSource = 'withdrawTronBandwidthSource'; static const withdrawTronEnergyUsed = 'withdrawTronEnergyUsed'; static const withdrawTronEnergyFee = 'withdrawTronEnergyFee'; static const withdrawTronEnergySource = 'withdrawTronEnergySource'; + static const withdrawTronAccountActivationFee = + 'withdrawTronAccountActivationFee'; static const withdrawTronFeeSummary = 'withdrawTronFeeSummary'; static const withdrawTronFeePaidIn = 'withdrawTronFeePaidIn'; static const withdrawTronBandwidthCovered = 'withdrawTronBandwidthCovered'; @@ -534,6 +541,11 @@ abstract class LocaleKeys { static const withdrawTronResourceNotUsed = 'withdrawTronResourceNotUsed'; static const withdrawTronFeeSummaryCharged = 'withdrawTronFeeSummaryCharged'; static const withdrawTronFeeSummaryCovered = 'withdrawTronFeeSummaryCovered'; + static const withdrawTronPreviewExpired = 'withdrawTronPreviewExpired'; + static const withdrawTronPreviewRefreshFailed = + 'withdrawTronPreviewRefreshFailed'; + static const withdrawTronPreviewRegenerate = 'withdrawTronPreviewRegenerate'; + static const withdrawAwaitingConfirmations = 'withdrawAwaitingConfirmations'; static const txHistoryFetchError = 'txHistoryFetchError'; static const txHistoryNoTransactions = 'txHistoryNoTransactions'; static const maxGapLimitReached = 'maxGapLimitReached'; @@ -663,6 +675,13 @@ abstract class LocaleKeys { static const accessHiddenWallet = 'accessHiddenWallet'; static const passphraseIsEmpty = 'passphraseIsEmpty'; static const selectWalletType = 'selectWalletType'; + static const walletImportTypeHdLabel = 'walletImportTypeHdLabel'; + static const walletImportTypeHdDescription = 'walletImportTypeHdDescription'; + static const walletImportTypeLegacyLabel = 'walletImportTypeLegacyLabel'; + static const walletImportTypeLegacyDescription = + 'walletImportTypeLegacyDescription'; + static const walletImportTypeHdDisabledHint = + 'walletImportTypeHdDisabledHint'; static const trezorNoAddresses = 'trezorNoAddresses'; static const trezorImportFailed = 'trezorImportFailed'; static const faucetFailureTitle = 'faucetFailureTitle'; @@ -775,7 +794,18 @@ abstract class LocaleKeys { static const expertMode = 'expertMode'; static const testCoins = 'testCoins'; static const enableTradingBot = 'enableTradingBot'; + static const saveOrders = 'saveOrders'; + static const saveOrdersRestartHint = 'saveOrdersRestartHint'; + static const exportMakerOrders = 'exportMakerOrders'; + static const importMakerOrders = 'importMakerOrders'; + static const noMakerOrdersToExport = 'noMakerOrdersToExport'; + static const makerOrdersExportSuccess = 'makerOrdersExportSuccess'; + static const makerOrdersExportFailed = 'makerOrdersExportFailed'; + static const makerOrdersImportSuccess = 'makerOrdersImportSuccess'; + static const makerOrdersImportFailed = 'makerOrdersImportFailed'; static const enableTestCoins = 'enableTestCoins'; + static const diagnosticLogging = 'diagnosticLogging'; + static const enableDiagnosticLogging = 'enableDiagnosticLogging'; static const makeMarket = 'makeMarket'; static const custom = 'custom'; static const edit = 'edit'; @@ -888,4 +918,126 @@ abstract class LocaleKeys { static const zhtlcAdvancedConfigurationHint = 'zhtlcAdvancedConfigurationHint'; static const zhtlcConfigButton = 'zhtlcConfigButton'; + static const kdfErrorGeneric = 'kdfErrorGeneric'; + static const kdfErrorNotSufficientBalance = 'kdfErrorNotSufficientBalance'; + static const kdfErrorNotSufficientPlatformBalanceForFee = + 'kdfErrorNotSufficientPlatformBalanceForFee'; + static const kdfErrorZeroBalanceToWithdrawMax = + 'kdfErrorZeroBalanceToWithdrawMax'; + static const kdfErrorAmountTooLow = 'kdfErrorAmountTooLow'; + static const kdfErrorNotEnoughNftsAmount = 'kdfErrorNotEnoughNftsAmount'; + static const kdfErrorInvalidAddress = 'kdfErrorInvalidAddress'; + static const kdfErrorFromAddressNotFound = 'kdfErrorFromAddressNotFound'; + static const kdfErrorUnexpectedFromAddress = 'kdfErrorUnexpectedFromAddress'; + static const kdfErrorMyAddressNotNftOwner = 'kdfErrorMyAddressNotNftOwner'; + static const kdfErrorNoSuchCoin = 'kdfErrorNoSuchCoin'; + static const kdfErrorCoinNotFound = 'kdfErrorCoinNotFound'; + static const kdfErrorCoinNotSupported = 'kdfErrorCoinNotSupported'; + static const kdfErrorCoinIsNotActive = 'kdfErrorCoinIsNotActive'; + static const kdfErrorCoinDoesntSupportWithdraw = + 'kdfErrorCoinDoesntSupportWithdraw'; + static const kdfErrorCoinDoesntSupportNftWithdraw = + 'kdfErrorCoinDoesntSupportNftWithdraw'; + static const kdfErrorNftProtocolNotSupported = + 'kdfErrorNftProtocolNotSupported'; + static const kdfErrorContractTypeDoesntSupportNft = + 'kdfErrorContractTypeDoesntSupportNft'; + static const kdfErrorTransport = 'kdfErrorTransport'; + static const kdfErrorTimeout = 'kdfErrorTimeout'; + static const kdfErrorTaskTimedOut = 'kdfErrorTaskTimedOut'; + static const kdfErrorInvalidResponse = 'kdfErrorInvalidResponse'; + static const kdfErrorUnreachableNodes = 'kdfErrorUnreachableNodes'; + static const kdfErrorAtLeastOneNodeRequired = + 'kdfErrorAtLeastOneNodeRequired'; + static const kdfErrorClientConnectionFailed = + 'kdfErrorClientConnectionFailed'; + static const kdfErrorConnectToNodeError = 'kdfErrorConnectToNodeError'; + static const kdfErrorActivationFailed = 'kdfErrorActivationFailed'; + static const kdfErrorCouldNotFetchBalance = 'kdfErrorCouldNotFetchBalance'; + static const kdfErrorUnsupportedChain = 'kdfErrorUnsupportedChain'; + static const kdfErrorChainIdNotSet = 'kdfErrorChainIdNotSet'; + static const kdfErrorNoChainIdSet = 'kdfErrorNoChainIdSet'; + static const kdfErrorInvalidFeePolicy = 'kdfErrorInvalidFeePolicy'; + static const kdfErrorInvalidFee = 'kdfErrorInvalidFee'; + static const kdfErrorInvalidGasApiConfig = 'kdfErrorInvalidGasApiConfig'; + static const kdfErrorNameTooLong = 'kdfErrorNameTooLong'; + static const kdfErrorDescriptionTooLong = 'kdfErrorDescriptionTooLong'; + static const kdfErrorNoSuchAccount = 'kdfErrorNoSuchAccount'; + static const kdfErrorNoEnabledAccount = 'kdfErrorNoEnabledAccount'; + static const kdfErrorAccountExistsAlready = 'kdfErrorAccountExistsAlready'; + static const kdfErrorUnknownAccount = 'kdfErrorUnknownAccount'; + static const kdfErrorLoadingAccount = 'kdfErrorLoadingAccount'; + static const kdfErrorSavingAccount = 'kdfErrorSavingAccount'; + static const kdfErrorHwError = 'kdfErrorHwError'; + static const kdfErrorHwContextNotInitialized = + 'kdfErrorHwContextNotInitialized'; + static const kdfErrorCoinDoesntSupportTrezor = + 'kdfErrorCoinDoesntSupportTrezor'; + static const kdfErrorInvalidHardwareWalletCall = + 'kdfErrorInvalidHardwareWalletCall'; + static const kdfErrorNotSupported = 'kdfErrorNotSupported'; + static const kdfErrorVolumeTooLow = 'kdfErrorVolumeTooLow'; + static const kdfErrorMyRecentSwapsError = 'kdfErrorMyRecentSwapsError'; + static const kdfErrorSwapInfoNotAvailable = 'kdfErrorSwapInfoNotAvailable'; + static const kdfErrorInvalidRequest = 'kdfErrorInvalidRequest'; + static const kdfErrorInvalidPayload = 'kdfErrorInvalidPayload'; + static const kdfErrorInvalidMemo = 'kdfErrorInvalidMemo'; + static const kdfErrorInvalidConfiguration = 'kdfErrorInvalidConfiguration'; + static const kdfErrorPrivKeyPolicyNotAllowed = + 'kdfErrorPrivKeyPolicyNotAllowed'; + static const kdfErrorUnexpectedDerivationMethod = + 'kdfErrorUnexpectedDerivationMethod'; + static const kdfErrorActionNotAllowed = 'kdfErrorActionNotAllowed'; + static const kdfErrorUnexpectedUserAction = 'kdfErrorUnexpectedUserAction'; + static const kdfErrorBroadcastExpected = 'kdfErrorBroadcastExpected'; + static const kdfErrorDbError = 'kdfErrorDbError'; + static const kdfErrorWalletStorageError = 'kdfErrorWalletStorageError'; + static const kdfErrorHDWalletStorageError = 'kdfErrorHDWalletStorageError'; + static const kdfErrorInternal = 'kdfErrorInternal'; + static const kdfErrorInternalError = 'kdfErrorInternalError'; + static const kdfErrorUnsupportedError = 'kdfErrorUnsupportedError'; + static const kdfErrorSigningError = 'kdfErrorSigningError'; + static const kdfErrorSystemTimeError = 'kdfErrorSystemTimeError'; + static const kdfErrorNumConversError = 'kdfErrorNumConversError'; + static const kdfErrorIOError = 'kdfErrorIOError'; + static const kdfErrorRpcError = 'kdfErrorRpcError'; + static const kdfErrorRpcTaskError = 'kdfErrorRpcTaskError'; + static const kdfErrorInvalidBip44Chain = 'kdfErrorInvalidBip44Chain'; + static const kdfErrorBip32Error = 'kdfErrorBip32Error'; + static const kdfErrorInvalidPath = 'kdfErrorInvalidPath'; + static const kdfErrorInvalidPathToAddress = 'kdfErrorInvalidPathToAddress'; + static const kdfErrorDeserializingDerivationPath = + 'kdfErrorDeserializingDerivationPath'; + static const kdfErrorInvalidSwapContractAddr = + 'kdfErrorInvalidSwapContractAddr'; + static const kdfErrorInvalidFallbackSwapContract = + 'kdfErrorInvalidFallbackSwapContract'; + static const kdfErrorCustomTokenError = 'kdfErrorCustomTokenError'; + static const kdfErrorGetNftInfoError = 'kdfErrorGetNftInfoError'; + static const kdfErrorMetamaskError = 'kdfErrorMetamaskError'; + static const kdfErrorWalletConnectError = 'kdfErrorWalletConnectError'; + static const sdk_errors_network_unavailable = + 'sdk_errors.network_unavailable'; + static const sdk_errors_timeout = 'sdk_errors.timeout'; + static const sdk_errors_invalid_response = 'sdk_errors.invalid_response'; + static const sdk_errors_insufficient_funds = 'sdk_errors.insufficient_funds'; + static const sdk_errors_insufficient_gas = 'sdk_errors.insufficient_gas'; + static const sdk_errors_zero_balance = 'sdk_errors.zero_balance'; + static const sdk_errors_amount_too_low = 'sdk_errors.amount_too_low'; + static const sdk_errors_invalid_address = 'sdk_errors.invalid_address'; + static const sdk_errors_invalid_fee = 'sdk_errors.invalid_fee'; + static const sdk_errors_invalid_memo = 'sdk_errors.invalid_memo'; + static const sdk_errors_asset_not_activated = + 'sdk_errors.asset_not_activated'; + static const sdk_errors_activation_failed = 'sdk_errors.activation_failed'; + static const sdk_errors_user_cancelled = 'sdk_errors.user_cancelled'; + static const sdk_errors_hardware_failure = 'sdk_errors.hardware_failure'; + static const sdk_errors_not_supported = 'sdk_errors.not_supported'; + static const sdk_errors_auth_invalid_credentials = + 'sdk_errors.auth_invalid_credentials'; + static const sdk_errors_auth_unauthorized = 'sdk_errors.auth_unauthorized'; + static const sdk_errors_auth_wallet_not_found = + 'sdk_errors.auth_wallet_not_found'; + static const sdk_errors_general = 'sdk_errors.general'; + static const sdk_errors = 'sdk_errors'; } diff --git a/lib/services/arrr_activation/arrr_activation_service.dart b/lib/services/arrr_activation/arrr_activation_service.dart index 787780e02c..f6abd44b5f 100644 --- a/lib/services/arrr_activation/arrr_activation_service.dart +++ b/lib/services/arrr_activation/arrr_activation_service.dart @@ -7,6 +7,7 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; import 'package:mutex/mutex.dart'; import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/shared/utils/kdf_error_display.dart'; import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; import 'arrr_config.dart'; @@ -118,7 +119,7 @@ class ArrrActivationService { e, stackTrace, ); - return ArrrActivationResultError('Failed to request configuration: $e'); + return ArrrActivationResultError(formatKdfUserFacingError(e)); } try { diff --git a/lib/shared/utils/kdf_error_display.dart b/lib/shared/utils/kdf_error_display.dart index 35bb443c86..1325628ee2 100644 --- a/lib/shared/utils/kdf_error_display.dart +++ b/lib/shared/utils/kdf_error_display.dart @@ -1,5 +1,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +// ignore: implementation_imports -- not exported from komodo_defi_sdk public API +import 'package:komodo_defi_sdk/src/activation/activation_exceptions.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; /// Extension on [MmRpcException] providing localized user-friendly messages. /// @@ -69,3 +73,61 @@ extension GeneralErrorLocalizedMessage on GeneralErrorResponse { return translated; } } + +/// Resolves [error] to a single user-facing string using the same mapping as +/// [KdfErrorLocalizedMessage] / [GeneralErrorLocalizedMessage] where applicable, +/// plus [SdkError] locale keys and a few common wrapper types. +String formatKdfUserFacingError(Object error) { + if (error is MmRpcException) { + return error.localizedMessage; + } + if (error is GeneralErrorResponse) { + return error.localizedMessage; + } + if (error is SdkError) { + final localized = error.messageKey.tr(args: error.messageArgs); + return localized == error.messageKey ? error.fallbackMessage : localized; + } + if (error is WithdrawalException) { + return error.message; + } + if (error is ActivationFailedException) { + final original = error.originalError; + if (original != null) { + return formatKdfUserFacingError(original); + } + return error.message; + } + + final raw = error.toString().trim(); + if (raw.isEmpty) { + return LocaleKeys.somethingWrong.tr(); + } + + const exceptionPrefix = 'Exception: '; + if (raw.startsWith(exceptionPrefix)) { + final message = raw.substring(exceptionPrefix.length).trim(); + if (message.isNotEmpty) { + return message; + } + } + + return raw; +} + +/// Technical detail string for expandable error UI (mirrors withdraw-form logic). +String extractKdfTechnicalDetails(Object error) { + if (error is SdkError) { + return error.fallbackMessage; + } + if (error is MmRpcException) { + return error.message ?? error.toString(); + } + if (error is GeneralErrorResponse) { + return error.error ?? error.toString(); + } + if (error is WithdrawalException) { + return error.message; + } + return error.toString(); +} diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart new file mode 100644 index 0000000000..6860105077 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +typedef FetchBalance = Future Function(); + +/// Confirms coin-details balance before the UI renders numeric values. +/// +/// The controller treats initial cached values as unconfirmed and transitions +/// to confirmed state when: +/// 1) a bootstrap `getBalance` call succeeds, or +/// 2) a stream update arrives after at least one bootstrap attempt. +class CoinDetailsBalanceConfirmationController extends ChangeNotifier { + CoinDetailsBalanceConfirmationController({ + required FetchBalance fetchConfirmedBalance, + BalanceInfo? initialBalance, + this.maxStartupRetries = 2, + this.retryBackoffBase = const Duration(milliseconds: 300), + }) : _fetchConfirmedBalance = fetchConfirmedBalance, + _latestBalance = initialBalance; + + final FetchBalance _fetchConfirmedBalance; + final int maxStartupRetries; + final Duration retryBackoffBase; + + BalanceInfo? _latestBalance; + bool _isConfirmed = false; + bool _isBootstrapInFlight = false; + bool _hasCompletedBootstrapAttempt = false; + int _startupRetryAttempts = 0; + bool _isDisposed = false; + + BalanceInfo? get latestBalance => _latestBalance; + bool get isConfirmed => _isConfirmed; + bool get isBootstrapping => _isBootstrapInFlight; + int get startupRetryAttempts => _startupRetryAttempts; + + Future bootstrap() async { + if (_isDisposed || _isConfirmed || _isBootstrapInFlight) return; + + var didSucceed = false; + _isBootstrapInFlight = true; + _notifyListenersIfAlive(); + + try { + final balance = await _fetchConfirmedBalance(); + if (_isDisposed) return; + _latestBalance = balance; + _isConfirmed = true; + didSucceed = true; + } catch (_) { + // Best effort. Startup errors are handled with bounded retries. + } finally { + _hasCompletedBootstrapAttempt = true; + _isBootstrapInFlight = false; + if (!_isDisposed) { + _notifyListenersIfAlive(); + } + } + + if (!didSucceed && !_isDisposed && !_isConfirmed) { + unawaited(_scheduleStartupRetry()); + } + } + + void onStreamBalance(BalanceInfo balance) { + if (_isDisposed) return; + + _latestBalance = balance; + + if (!_isConfirmed && _hasCompletedBootstrapAttempt) { + _isConfirmed = true; + } + + _notifyListenersIfAlive(); + } + + Future onStartupStreamError() async { + await _scheduleStartupRetry(); + } + + Future _scheduleStartupRetry() async { + if (_isDisposed || _isConfirmed) return; + if (_startupRetryAttempts >= maxStartupRetries) return; + + _startupRetryAttempts += 1; + _notifyListenersIfAlive(); + + final delayMs = retryBackoffBase.inMilliseconds * _startupRetryAttempts; + await Future.delayed(Duration(milliseconds: delayMs)); + + if (_isDisposed || _isConfirmed) return; + await bootstrap(); + } + + void _notifyListenersIfAlive() { + if (_isDisposed) return; + notifyListeners(); + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); + } +} diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart index ddcc686c5d..9b64188c5f 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -36,6 +38,7 @@ import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_addresses.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart'; import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; @@ -60,6 +63,8 @@ class _CoinDetailsInfoState extends State with SingleTickerProviderStateMixin { Transaction? _selectedTransaction; final ScrollController _scrollController = ScrollController(); + final GlobalKey _transactionsSectionKey = GlobalKey(); + final GlobalKey _addressesSectionKey = GlobalKey(); String? get _walletId => RepositoryProvider.of(context).state.currentUser?.walletId.name; @@ -135,6 +140,10 @@ class _CoinDetailsInfoState extends State setPageType: widget.setPageType, setTransaction: _selectTransaction, scrollController: _scrollController, + transactionsSectionKey: _transactionsSectionKey, + addressesSectionKey: _addressesSectionKey, + onShowTransactions: _scrollToTransactions, + onShowAddresses: _scrollToAddresses, ); } return _DesktopContent( @@ -143,6 +152,10 @@ class _CoinDetailsInfoState extends State setPageType: widget.setPageType, setTransaction: _selectTransaction, scrollController: _scrollController, + transactionsSectionKey: _transactionsSectionKey, + addressesSectionKey: _addressesSectionKey, + onShowTransactions: _scrollToTransactions, + onShowAddresses: _scrollToAddresses, ); } @@ -168,6 +181,25 @@ class _CoinDetailsInfoState extends State }); } + Future _scrollToSection(GlobalKey sectionKey) async { + final targetContext = sectionKey.currentContext; + if (targetContext == null) { + return; + } + + await Scrollable.ensureVisible( + targetContext, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + ); + } + + Future _scrollToTransactions() => + _scrollToSection(_transactionsSectionKey); + + Future _scrollToAddresses() => _scrollToSection(_addressesSectionKey); + void _onBackButtonPressed() { if (_haveTransaction) { _selectTransaction(null); @@ -198,6 +230,10 @@ class _DesktopContent extends StatelessWidget { required this.setPageType, required this.setTransaction, required this.scrollController, + required this.transactionsSectionKey, + required this.addressesSectionKey, + required this.onShowTransactions, + required this.onShowAddresses, }); final Coin coin; @@ -205,6 +241,10 @@ class _DesktopContent extends StatelessWidget { final void Function(CoinPageType) setPageType; final Function(Transaction?) setTransaction; final ScrollController scrollController; + final GlobalKey transactionsSectionKey; + final GlobalKey addressesSectionKey; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { @@ -226,17 +266,24 @@ class _DesktopContent extends StatelessWidget { child: _DesktopCoinDetails( coin: coin, setPageType: setPageType, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, ), ), const SliverToBoxAdapter(child: SizedBox(height: 20)), TransactionTable( + key: transactionsSectionKey, coin: coin, selectedTransaction: selectedTransaction, setTransaction: setTransaction, ), if (selectedTransaction == null) ...[ const SliverToBoxAdapter(child: SizedBox(height: 20)), - CoinAddresses(coin: coin, setPageType: setPageType), + CoinAddresses( + key: addressesSectionKey, + coin: coin, + setPageType: setPageType, + ), ], ], ), @@ -246,10 +293,17 @@ class _DesktopContent extends StatelessWidget { } class _DesktopCoinDetails extends StatelessWidget { - const _DesktopCoinDetails({required this.coin, required this.setPageType}); + const _DesktopCoinDetails({ + required this.coin, + required this.setPageType, + required this.onShowTransactions, + required this.onShowAddresses, + }); final Coin coin; final void Function(CoinPageType) setPageType; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { @@ -289,6 +343,12 @@ class _DesktopCoinDetails extends StatelessWidget { coin: coin, ), ), + const Gap(12), + _SectionAnchorChips( + isMobile: false, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, + ), const Gap(16), _CoinDetailsMarketMetricsTabBar(coin: coin), ], @@ -304,6 +364,10 @@ class _MobileContent extends StatelessWidget { required this.setPageType, required this.setTransaction, required this.scrollController, + required this.transactionsSectionKey, + required this.addressesSectionKey, + required this.onShowTransactions, + required this.onShowAddresses, }); final Coin coin; @@ -311,6 +375,10 @@ class _MobileContent extends StatelessWidget { final void Function(CoinPageType) setPageType; final Function(Transaction?) setTransaction; final ScrollController scrollController; + final GlobalKey transactionsSectionKey; + final GlobalKey addressesSectionKey; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { @@ -326,13 +394,20 @@ class _MobileContent extends StatelessWidget { coin: coin, setPageType: setPageType, context: context, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, ), ), const SliverToBoxAdapter(child: SizedBox(height: 20)), if (selectedTransaction == null) - CoinAddresses(coin: coin, setPageType: setPageType), + CoinAddresses( + key: addressesSectionKey, + coin: coin, + setPageType: setPageType, + ), const SliverToBoxAdapter(child: SizedBox(height: 20)), TransactionTable( + key: transactionsSectionKey, coin: coin, selectedTransaction: selectedTransaction, setTransaction: setTransaction, @@ -348,11 +423,15 @@ class _CoinDetailsInfoHeader extends StatelessWidget { required this.coin, required this.setPageType, required this.context, + required this.onShowTransactions, + required this.onShowAddresses, }); final Coin coin; final void Function(CoinPageType p1) setPageType; final BuildContext context; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; @override Widget build(BuildContext context) { @@ -387,6 +466,12 @@ class _CoinDetailsInfoHeader extends StatelessWidget { coin: coin, ), ), + _SectionAnchorChips( + isMobile: true, + onShowTransactions: onShowTransactions, + onShowAddresses: onShowAddresses, + ), + const SizedBox(height: 12), _CoinDetailsMarketMetricsTabBar(coin: coin), ], ), @@ -394,6 +479,52 @@ class _CoinDetailsInfoHeader extends StatelessWidget { } } +class _SectionAnchorChips extends StatelessWidget { + const _SectionAnchorChips({ + required this.isMobile, + required this.onShowTransactions, + required this.onShowAddresses, + }); + + final bool isMobile; + final Future Function() onShowTransactions; + final Future Function() onShowAddresses; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final chipBackground = themeData.colorScheme.surfaceContainerHighest; + final chipLabelStyle = themeData.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ); + + return Align( + alignment: isMobile ? Alignment.center : Alignment.centerLeft, + child: Wrap( + alignment: isMobile ? WrapAlignment.center : WrapAlignment.start, + spacing: 8, + runSpacing: 8, + children: [ + ActionChip( + avatar: const Icon(Icons.receipt_long_outlined, size: 18), + label: Text(LocaleKeys.transactions.tr(), style: chipLabelStyle), + backgroundColor: chipBackground, + side: BorderSide.none, + onPressed: onShowTransactions, + ), + ActionChip( + avatar: const Icon(Icons.account_balance_wallet_outlined, size: 18), + label: Text(LocaleKeys.addresses.tr(), style: chipLabelStyle), + backgroundColor: chipBackground, + side: BorderSide.none, + onPressed: onShowAddresses, + ), + ], + ), + ); + } +} + class _CoinDetailsMarketMetricsTabBar extends StatefulWidget { const _CoinDetailsMarketMetricsTabBar({required this.coin}); @@ -553,84 +684,207 @@ class _CoinDetailsMarketMetricsTabBarState } } -class _Balance extends StatelessWidget { +class _Balance extends StatefulWidget { const _Balance({required this.coin}); final Coin coin; @override - Widget build(BuildContext context) { - final ThemeData themeData = Theme.of(context); - final hideBalances = context.select( - (SettingsBloc bloc) => bloc.state.hideBalances, + State<_Balance> createState() => _BalanceState(); +} + +class CoinDetailsBalanceContent extends StatelessWidget { + const CoinDetailsBalanceContent({ + required this.coin, + required this.hideBalances, + required this.isConfirmed, + required this.latestBalance, + this.fiatBalance, + super.key, + }); + + final Coin coin; + final bool hideBalances; + final bool isConfirmed; + final BalanceInfo? latestBalance; + final Widget? fiatBalance; + + Widget _buildGhostValue(ThemeData themeData) { + final style = themeData.textTheme.titleMedium?.copyWith( + fontSize: isMobile ? 25 : 22, + fontWeight: FontWeight.w700, + color: theme.custom.headerFloatBoxColor, + height: 1.1, ); - final initialBalance = context.sdk.balances.lastKnown(coin.id); - final balanceStream = context.sdk.balances.watchBalance(coin.id); - - return StreamBuilder( - stream: balanceStream, - initialData: initialBalance, - builder: (context, snapshot) { - final balance = snapshot.data?.spendable.toDouble(); - final value = hideBalances - ? maskedBalanceText - : balance == null - ? kBalancePlaceholder - : doubleToString(balance); - - return Column( - crossAxisAlignment: isMobile - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (isMobile) - const SizedBox.shrink() - else - Text( - LocaleKeys.yourBalance.tr(), - style: themeData.textTheme.titleMedium!.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - color: theme.custom.headerFloatBoxColor, - ), - ), - Flexible( - child: Row( - mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, - mainAxisAlignment: isMobile - ? MainAxisAlignment.center - : MainAxisAlignment.start, - children: [ - Flexible( - child: AutoScrollText( - key: const Key('coin-details-balance'), - text: value, - isSelectable: true, - style: themeData.textTheme.titleMedium!.copyWith( - fontSize: isMobile ? 25 : 22, - fontWeight: FontWeight.w700, + + return Row( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Container( + key: const Key('coin-details-balance'), + width: isMobile ? 120 : 132, + height: isMobile ? 30 : 24, + decoration: BoxDecoration( + color: (style?.color ?? themeData.colorScheme.onSurface).withValues( + alpha: 0.22, + ), + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(width: 8), + Text( + Coin.normalizeAbbr(coin.abbr), + style: themeData.textTheme.titleSmall!.copyWith( + fontSize: isMobile ? 25 : 20, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor.withValues(alpha: 0.75), + height: 1.1, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final showGhost = !hideBalances && !isConfirmed; + final balance = latestBalance?.spendable.toDouble(); + final value = hideBalances + ? maskedBalanceText + : showGhost + ? '' + : balance == null + ? kBalancePlaceholder + : doubleToString(balance); + + return Column( + crossAxisAlignment: isMobile + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (isMobile) + const SizedBox.shrink() + else + Text( + LocaleKeys.yourBalance.tr(), + style: themeData.textTheme.titleMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor, + ), + ), + Flexible( + child: showGhost + ? _buildGhostValue(themeData) + : Row( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Flexible( + child: AutoScrollText( + key: const Key('coin-details-balance'), + text: value, + isSelectable: true, + style: themeData.textTheme.titleMedium!.copyWith( + fontSize: isMobile ? 25 : 22, + fontWeight: FontWeight.w700, + color: theme.custom.headerFloatBoxColor, + height: 1.1, + ), + ), + ), + const SizedBox(width: 5), + Text( + Coin.normalizeAbbr(coin.abbr), + style: themeData.textTheme.titleSmall!.copyWith( + fontSize: isMobile ? 25 : 20, + fontWeight: FontWeight.w500, color: theme.custom.headerFloatBoxColor, height: 1.1, ), ), - ), - const SizedBox(width: 5), - Text( - Coin.normalizeAbbr(coin.abbr), - style: themeData.textTheme.titleSmall!.copyWith( - fontSize: isMobile ? 25 : 20, - fontWeight: FontWeight.w500, - color: theme.custom.headerFloatBoxColor, - height: 1.1, - ), - ), - ], - ), - ), - if (!isMobile) _FiatBalance(coin: coin), - ], + ], + ), + ), + if (!isMobile && !showGhost) fiatBalance ?? _FiatBalance(coin: coin), + ], + ); + } +} + +class _BalanceState extends State<_Balance> { + static const int _maxStartupRetries = 2; + + late CoinDetailsBalanceConfirmationController _confirmationController; + StreamSubscription? _balanceSubscription; + + @override + void initState() { + super.initState(); + _initBindings(); + } + + @override + void didUpdateWidget(covariant _Balance oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.coin.id != widget.coin.id) { + _tearDownBindings(); + _initBindings(); + } + } + + void _initBindings() { + final sdk = context.sdk; + _confirmationController = CoinDetailsBalanceConfirmationController( + initialBalance: sdk.balances.lastKnown(widget.coin.id), + fetchConfirmedBalance: () => sdk.balances.getBalance(widget.coin.id), + maxStartupRetries: _maxStartupRetries, + ); + + _balanceSubscription = sdk.balances + .watchBalance(widget.coin.id) + .listen( + _confirmationController.onStreamBalance, + onError: (Object _, StackTrace __) { + unawaited(_confirmationController.onStartupStreamError()); + }, ); - }, + + unawaited(_confirmationController.bootstrap()); + } + + void _tearDownBindings() { + _balanceSubscription?.cancel(); + _balanceSubscription = null; + _confirmationController.dispose(); + } + + @override + void dispose() { + _tearDownBindings(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hideBalances = context.select( + (SettingsBloc bloc) => bloc.state.hideBalances, + ); + + return ListenableBuilder( + listenable: _confirmationController, + builder: (context, _) => CoinDetailsBalanceContent( + coin: widget.coin, + hideBalances: hideBalances, + isConfirmed: _confirmationController.isConfirmed, + latestBalance: _confirmationController.latestBalance, + ), ); } } diff --git a/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart b/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart index 9093895b0f..48d4fc2dab 100644 --- a/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart +++ b/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart @@ -170,9 +170,7 @@ class _KmdRewardsInfoState extends State { ?.withValues(alpha: 0.4), ), ), - const SizedBox( - height: 30.0, - ), + const SizedBox(height: 30.0), UiBorderButton( width: 160, height: 38, @@ -204,19 +202,16 @@ class _KmdRewardsInfoState extends State { } Widget _buildMessage() { - final String message = - _successMessage.isEmpty ? _errorMessage : _successMessage; + final String message = _successMessage.isEmpty + ? _errorMessage + : _successMessage; return message.isEmpty ? const SizedBox.shrink() : Container( margin: const EdgeInsets.only(top: 20), padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all( - color: _messageColor, - ), - ), + decoration: BoxDecoration(border: Border.all(color: _messageColor)), child: SelectableText( message, style: TextStyle(color: _messageColor), @@ -380,8 +375,10 @@ class _KmdRewardsInfoState extends State { alignment: const Alignment(-1, 0), child: Text( LocaleKeys.status.tr(), - style: - const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + ), ), ), ), @@ -418,16 +415,17 @@ class _KmdRewardsInfoState extends State { }); context.read().logEvent( - RewardClaimInitiatedEventData( - asset: widget.coin.abbr, - expectedRewardAmount: _totalReward ?? 0, - ), - ); + RewardClaimInitiatedEventData( + asset: widget.coin.abbr, + expectedRewardAmount: _totalReward ?? 0, + ), + ); final coinsRepository = RepositoryProvider.of(context); final kmdRewardsBloc = RepositoryProvider.of(context); - final BlocResponse response = - await kmdRewardsBloc.claim(context); + final BlocResponse response = await kmdRewardsBloc.claim( + context, + ); final BaseError? error = response.error; if (error != null) { setState(() { @@ -435,11 +433,11 @@ class _KmdRewardsInfoState extends State { _errorMessage = error.message; }); context.read().logEvent( - RewardClaimFailureEventData( - asset: widget.coin.abbr, - failReason: error.message, - ), - ); + RewardClaimFailureEventData( + asset: widget.coin.abbr, + failReason: error.message, + ), + ); return; } @@ -447,20 +445,24 @@ class _KmdRewardsInfoState extends State { context.read().add(CoinsBalancesRefreshed()); await _updateInfoUntilSuccessOrTimeOut(30000); - final String reward = - doubleToString(double.tryParse(response.result!) ?? 0); - final double? usdPrice = - coinsRepository.getUsdPriceByAmount(response.result!, 'KMD'); + final String reward = doubleToString( + double.tryParse(response.result!) ?? 0, + ); + final rewardAmount = double.tryParse(response.result!) ?? 0; + final double? usdPrice = coinsRepository.getUsdPriceForAmount( + rewardAmount, + 'KMD', + ); final String formattedUsdPrice = cutTrailingZeros(formatAmt(usdPrice ?? 0)); setState(() { _isClaiming = false; }); context.read().logEvent( - RewardClaimSuccessEventData( - asset: widget.coin.abbr, - rewardAmount: double.tryParse(response.result!) ?? 0, - ), - ); + RewardClaimSuccessEventData( + asset: widget.coin.abbr, + rewardAmount: rewardAmount, + ), + ); widget.onSuccess(reward, formattedUsdPrice); } @@ -479,8 +481,9 @@ class _KmdRewardsInfoState extends State { Future _updateInfoUntilSuccessOrTimeOut(int timeOut) async { _updateTimer ??= DateTime.now().millisecondsSinceEpoch; - final List prevRewards = - List.from(_rewards ?? []); + final List prevRewards = List.from( + _rewards ?? [], + ); await _updateRewardsInfo(); @@ -502,8 +505,10 @@ class _KmdRewardsInfoState extends State { final kmdRewardsBloc = RepositoryProvider.of(context); final double? total = await kmdRewardsBloc.getTotal(context); final List currentRewards = await kmdRewardsBloc.getInfo(); - final double? totalUsd = - coinsRepository.getUsdPriceByAmount((total ?? 0).toString(), 'KMD'); + final double? totalUsd = coinsRepository.getUsdPriceForAmount( + total ?? 0, + 'KMD', + ); if (!mounted) return; setState(() { diff --git a/lib/views/wallet/coin_details/transactions/transaction_details.dart b/lib/views/wallet/coin_details/transactions/transaction_details.dart index 8265cc54e6..cce8900c23 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_details.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_details.dart @@ -18,12 +18,16 @@ class TransactionDetails extends StatelessWidget { required this.transaction, required this.onClose, required this.coin, + this.usdPriceResolver, + this.onLaunchExplorer, super.key, }); final Transaction transaction; final void Function() onClose; final Coin coin; + final double? Function(num amount, String coinAbbr)? usdPriceResolver; + final void Function(String url)? onLaunchExplorer; @override Widget build(BuildContext context) { @@ -162,11 +166,15 @@ class TransactionDetails extends StatelessWidget { Widget _buildBalanceChanges(BuildContext context) { final String formatted = formatDexAmt(transaction.amount.toDouble().abs()); final String sign = transaction.amount.toDouble() > 0 ? '+' : '-'; - final coinsBloc = RepositoryProvider.of(context); - final double? usd = coinsBloc.getUsdPriceByAmount( - formatted, - transaction.assetId.id, - ); + final double? usd = + usdPriceResolver?.call( + transaction.amount.toDouble().abs(), + transaction.assetId.id, + ) ?? + RepositoryProvider.of(context).getUsdPriceForAmount( + transaction.amount.toDouble().abs(), + transaction.assetId.id, + ); final String formattedUsd = formatAmt(usd ?? 0); final String value = '$sign $formatted ${Coin.normalizeAbbr(transaction.assetId.id)} (\$$formattedUsd)'; @@ -197,7 +205,12 @@ class TransactionDetails extends StatelessWidget { color: theme.custom.defaultGradientButtonTextColor, ), onPressed: () { - launchURLString(getTxExplorerUrl(coin, transaction.txHash ?? '')); + final url = getTxExplorerUrl(coin, transaction.txHash ?? ''); + if (onLaunchExplorer != null) { + onLaunchExplorer!(url); + return; + } + launchURLString(url); }, text: LocaleKeys.viewOnExplorer.tr(), ), @@ -218,7 +231,6 @@ class TransactionDetails extends StatelessWidget { } Widget _buildFee(BuildContext context) { - final coinsRepository = RepositoryProvider.of(context); final String title = LocaleKeys.fees.tr(); final String value; @@ -231,14 +243,15 @@ class TransactionDetails extends StatelessWidget { fontWeight: FontWeight.w500, ); } else { - final String formattedFee = transaction.fee!.formatTotal(); - final double? usd = coinsRepository.getUsdPriceByAmount( - formattedFee, - _feeCoin, - ); + final fee = transaction.fee!; + final String feeAmount = formatDexAmt(fee.totalFee.toDouble()); + final double? usd = + usdPriceResolver?.call(fee.totalFee.toDouble(), _feeCoin) ?? + RepositoryProvider.of( + context, + ).getUsdPriceForAmount(fee.totalFee.toDouble(), _feeCoin); final String formattedUsd = formatAmt(usd ?? 0); - value = - '- ${Coin.normalizeAbbr(_feeCoin)} $formattedFee (\$$formattedUsd)'; + value = '- ${Coin.normalizeAbbr(_feeCoin)} $feeAmount (\$$formattedUsd)'; valueStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 14, fontWeight: FontWeight.w500, diff --git a/lib/views/wallet/coin_details/transactions/transaction_list_item.dart b/lib/views/wallet/coin_details/transactions/transaction_list_item.dart index 2cb7fb29d7..b61ccd81f1 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_list_item.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_list_item.dart @@ -12,7 +12,6 @@ import 'package:web_dex/shared/ui/custom_tooltip.dart'; import 'package:web_dex/shared/utils/formatters.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:komodo_ui_kit/komodo_ui_kit.dart'; class TransactionListRow extends StatefulWidget { @@ -281,8 +280,8 @@ class _TransactionListRowState extends State { Widget _buildUsdChanges() { final coinsBloc = context.read(); - final double? usdChanges = coinsBloc.state.getUsdPriceByAmount( - _displayAmount.toString(), + final double? usdChanges = coinsBloc.state.getUsdPriceForAmount( + _displayAmount.toDouble(), widget.coinAbbr, ); return AutoScrollText( diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart index 64b54e9e67..6684a547fa 100644 --- a/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart @@ -2,7 +2,6 @@ import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:komodo_ui/komodo_ui.dart'; import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -77,7 +76,7 @@ class SendCompleteForm extends StatelessWidget { ), if (state.result?.txHash != null) _TransactionHash( - feeValue: feeValue!.formatTotal(), + feeValue: feeValue!.totalFee.toString(), feeCoin: feeValue.coin, txHash: state.result!.txHash, usdFeePrice: state.usdFeePrice, 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 9aaf44c027..a142efb476 100644 --- a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -26,18 +26,26 @@ import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/asset_amount_with_fiat.dart'; -import 'package:web_dex/shared/widgets/copied_text.dart' show CopiedTextV2; +import 'package:web_dex/shared/widgets/copied_text.dart' + show CopiedText, CopiedTextV2; 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/fill_form/fields/fill_form_memo.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/trezor_withdraw_progress_dialog.dart'; import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart'; -import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; bool _isMemoSupportedProtocol(Asset asset) { final protocol = asset.protocol; return protocol is TendermintProtocol || protocol is ZhtlcProtocol; } +AssetId _resolveFeeAssetId(BuildContext context, Asset asset, FeeInfo fee) { + if (fee.coin.isEmpty || fee.coin == asset.id.id) { + return asset.id; + } + + return context.sdk.getSdkAsset(fee.coin).id; +} + class WithdrawForm extends StatefulWidget { final Asset asset; final VoidCallback onSuccess; @@ -411,181 +419,411 @@ class ZhtlcPreviewDelayNote extends StatelessWidget { } class WithdrawPreviewDetails extends StatelessWidget { - final WithdrawalPreview preview; - final double widthThreshold; - final double minPadding; - final double maxPadding; + const WithdrawPreviewDetails({required this.state, super.key}); + + final WithdrawFormState state; + + @override + Widget build(BuildContext context) { + final preview = state.preview!; + + return LayoutBuilder( + builder: (context, constraints) { + final useWideLayout = constraints.maxWidth >= 560; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _WithdrawSectionCard( + child: _WithdrawPreviewSummary( + state: state, + preview: preview, + useWideLayout: useWideLayout, + ), + ), + const SizedBox(height: 16), + _WithdrawSectionCard( + child: _WithdrawPreviewDestination( + state: state, + preview: preview, + useWideLayout: useWideLayout, + ), + ), + if (preview.fee is FeeInfoTron) ...[ + const SizedBox(height: 16), + _WithdrawTronDetailsCard(fee: preview.fee as FeeInfoTron), + ], + ], + ); + }, + ); + } +} - const WithdrawPreviewDetails({ +class _WithdrawPreviewSummary extends StatelessWidget { + const _WithdrawPreviewSummary({ + required this.state, required this.preview, - super.key, - this.widthThreshold = 400, - this.minPadding = 2, - this.maxPadding = 16, + required this.useWideLayout, }); - double _calculatePadding(double width) { - if (width >= widthThreshold) { - return maxPadding; - } + final WithdrawFormState state; + final WithdrawalPreview preview; + final bool useWideLayout; - // Scale padding linearly based on width below threshold - final ratio = width / widthThreshold; - final scaledPadding = minPadding + (maxPadding - minPadding) * ratio; + Color _warningBackground(BuildContext context) { + final theme = Theme.of(context); + return Colors.amber.withValues( + alpha: theme.brightness == Brightness.dark ? 0.22 : 0.16, + ); + } - return scaledPadding.clamp(minPadding, maxPadding); + Color _warningForeground(BuildContext context) { + final theme = Theme.of(context); + return theme.brightness == Brightness.dark + ? Colors.amber.shade200 + : Colors.amber.shade900; } @override Widget build(BuildContext context) { - final sdk = context.sdk; - - final assets = sdk.getSdkAsset(preview.coin); - final feeAssets = sdk.getSdkAsset(preview.fee.coin); - - return LayoutBuilder( - builder: (context, constraints) { - final padding = _calculatePadding(constraints.maxWidth); - final useRowLayout = constraints.maxWidth >= widthThreshold; + final theme = Theme.of(context); + final feeAssetId = _resolveFeeAssetId(context, state.asset, preview.fee); + final labelStyle = theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.72), + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ); + final amountStyle = theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ); + final feeStyle = theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + height: 1.15, + ); - return Card( - child: Padding( - padding: EdgeInsets.all(padding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (useRowLayout) - _buildRow( - LocaleKeys.amount.tr(), - AssetAmountWithFiat( - assetId: assets.id, - // netchange for withdrawals is expected to be negative - // so we display the absolute value here to avoid - // confusion with the negative sign - amount: preview.balanceChanges.netChange.abs(), - isAutoScrollEnabled: true, - ), - ) - else ...[ - Text( - LocaleKeys.amount.tr(), - style: Theme.of(context).textTheme.labelLarge, - ), + final leftContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AssetLogo.ofId(state.asset.id, size: 42), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(LocaleKeys.youSend.tr(), style: labelStyle), const SizedBox(height: 4), - AssetAmountWithFiat( - assetId: assets.id, - amount: preview.balanceChanges.netChange, - isAutoScrollEnabled: true, - ), - ], - const SizedBox(height: 16), - if (useRowLayout) - _buildRow( - LocaleKeys.fee.tr(), - AssetAmountWithFiat( - assetId: feeAssets.id, - amount: preview.fee.totalFee, - isAutoScrollEnabled: true, - ), - ) - else ...[ Text( - LocaleKeys.fee.tr(), - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 4), - AssetAmountWithFiat( - assetId: feeAssets.id, - amount: preview.fee.totalFee, - isAutoScrollEnabled: true, - ), - ], - if (preview.fee is FeeInfoTron) ...[ - const SizedBox(height: 16), - _buildTronFeeDetails( - context, - preview.fee as FeeInfoTron, - useRowLayout: useRowLayout, + state.asset.id.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), ], - const SizedBox(height: 16), - if (useRowLayout) - _buildRow( - LocaleKeys.recipientAddress.tr(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - for (final recipient in preview.to) - CopiedTextV2(copiedValue: recipient, fontSize: 14), - ], + ), + ), + ], + ), + const SizedBox(height: 20), + AssetAmountWithFiat( + assetId: state.asset.id, + amount: preview.balanceChanges.netChange.abs(), + style: amountStyle, + isAutoScrollEnabled: false, + ), + ], + ); + + final rightContent = Container( + width: useWideLayout ? null : double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(LocaleKeys.fee.tr(), style: labelStyle)), + if (state.isFeePriceExpensive) + Chip( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + label: Text( + LocaleKeys.withdrawHighFee.tr(), + style: theme.textTheme.labelSmall?.copyWith( + color: _warningForeground(context), + fontWeight: FontWeight.w700, ), - ) - else ...[ - Text( - LocaleKeys.recipientAddress.tr(), - style: Theme.of(context).textTheme.labelLarge, ), - const SizedBox(height: 4), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (final recipient in preview.to) - CopiedTextV2(copiedValue: recipient, fontSize: 14), - ], + backgroundColor: _warningBackground(context), + side: BorderSide.none, + ), + ], + ), + const SizedBox(height: 12), + AssetAmountWithFiat( + assetId: feeAssetId, + amount: preview.fee.totalFee, + style: feeStyle, + isAutoScrollEnabled: false, + ), + ], + ), + ); + + if (!useWideLayout) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [leftContent, const SizedBox(height: 16), rightContent], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 7, child: leftContent), + const SizedBox(width: 16), + Expanded(flex: 4, child: rightContent), + ], + ); + } +} + +class _WithdrawPreviewDestination extends StatelessWidget { + const _WithdrawPreviewDestination({ + required this.state, + required this.preview, + required this.useWideLayout, + }); + + final WithdrawFormState state; + final WithdrawalPreview preview; + final bool useWideLayout; + + Widget _buildAddressCard( + BuildContext context, { + required IconData icon, + required String label, + required Widget child, + }) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorScheme.primary.withValues(alpha: 0.04), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.35)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues( + alpha: 0.72, ), - ], - if (preview.memo?.isNotEmpty ?? false) ...[ - const SizedBox(height: 16), - if (useRowLayout) - _buildRow( - LocaleKeys.memo.tr(), - Text( - preview.memo!, - textAlign: TextAlign.right, - softWrap: true, - overflow: TextOverflow.visible, - ), - ) - else ...[ - Text( - LocaleKeys.memo.tr(), - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 4), - Text( - preview.memo!, - textAlign: TextAlign.left, - softWrap: true, - overflow: TextOverflow.visible, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ); + } + + Widget _buildSourceAddress(BuildContext context) { + final sourceAddress = state.selectedSourceAddress?.address; + final theme = Theme.of(context); + + if (sourceAddress == null || sourceAddress.isEmpty) { + return Text( + state.asset.id.name, + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ); + } + + return CopiedTextV2( + copiedValue: sourceAddress, + fontSize: 13, + iconSize: 14, + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.08), + textColor: theme.textTheme.bodyLarge?.color, + ); + } + + Widget _buildRecipientAddresses(BuildContext context) { + final theme = Theme.of(context); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final recipient in preview.to) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: CopiedTextV2( + copiedValue: recipient, + fontSize: 13, + iconSize: 14, + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.08, + ), + textColor: theme.textTheme.bodyLarge?.color, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final destinationTitle = Text( + LocaleKeys.withdrawDestination.tr(), + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ); + final routeIcon = Icon( + useWideLayout ? Icons.arrow_forward_rounded : Icons.south_rounded, + color: theme.colorScheme.primary, + size: 24, + ); + + final sourceCard = _buildAddressCard( + context, + icon: Icons.account_balance_wallet_outlined, + label: LocaleKeys.from.tr(), + child: _buildSourceAddress(context), + ); + final recipientCard = _buildAddressCard( + context, + icon: Icons.place_outlined, + label: LocaleKeys.to.tr(), + child: _buildRecipientAddresses(context), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + destinationTitle, + const SizedBox(height: 16), + if (useWideLayout) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: sourceCard), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: routeIcon, + ), + Expanded(child: recipientCard), + ], + ) + else ...[ + sourceCard, + const SizedBox(height: 12), + Center(child: routeIcon), + const SizedBox(height: 12), + recipientCard, + ], + if (preview.memo?.isNotEmpty ?? false) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.memo.tr(), + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues( + alpha: 0.72, ), - ], - ], + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + SelectableText(preview.memo!, style: theme.textTheme.bodyLarge), ], ), ), - ); - }, + ], + ], ); } +} + +class _WithdrawTronDetailsCard extends StatelessWidget { + const _WithdrawTronDetailsCard({required this.fee}); + + final FeeInfoTron fee; + + String _formatDecimal(Decimal value, {int precision = 8}) { + return value.toStringAsFixed(precision).replaceAll(RegExp(r'\.?0+$'), ''); + } - Widget _buildTronFeeDetails( - BuildContext context, - FeeInfoTron fee, { - required bool useRowLayout, + Widget _buildDetailRow( + BuildContext context, { + required String label, + required String value, + TextStyle? valueStyle, }) { - final labelStyle = Theme.of(context).textTheme.labelLarge; - final secondaryValueStyle = Theme.of(context).textTheme.bodySmall; + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 4, + child: Text( + value, + textAlign: TextAlign.right, + style: valueStyle ?? theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); final totalFee = fee.totalFee; - final hasTrxFee = totalFee > Decimal.zero; - final bandwidthUsedLabel = LocaleKeys.withdrawTronBandwidthUsed.tr(); - final bandwidthFeeLabel = LocaleKeys.withdrawTronBandwidthFee.tr(); - final bandwidthSourceLabel = LocaleKeys.withdrawTronBandwidthSource.tr(); - final energyUsedLabel = LocaleKeys.withdrawTronEnergyUsed.tr(); - final energyFeeLabel = LocaleKeys.withdrawTronEnergyFee.tr(); - final energySourceLabel = LocaleKeys.withdrawTronEnergySource.tr(); - final feeSummaryLabel = LocaleKeys.withdrawTronFeeSummary.tr(); final paidInCoin = LocaleKeys.withdrawTronFeePaidIn.tr(args: [fee.coin]); final bandwidthSource = fee.bandwidthFee > Decimal.zero ? paidInCoin @@ -595,133 +833,91 @@ class WithdrawPreviewDetails extends StatelessWidget { : fee.energyFee > Decimal.zero ? paidInCoin : LocaleKeys.withdrawTronEnergyCovered.tr(); - - final bandwidthFeeString = - '${_formatDecimal(fee.bandwidthFee)} ${fee.coin}'; - final energyFeeString = '${_formatDecimal(fee.energyFee)} ${fee.coin}'; - final chargeSummary = hasTrxFee + final chargeSummary = totalFee > Decimal.zero ? LocaleKeys.withdrawTronFeeSummaryCharged.tr( args: [_formatDecimal(totalFee), fee.coin], ) : LocaleKeys.withdrawTronFeeSummaryCovered.tr(args: [fee.coin]); - if (useRowLayout) { - return Column( - children: [ - _buildRow(bandwidthUsedLabel, Text('${fee.bandwidthUsed}')), - const SizedBox(height: 8), - _buildRow(bandwidthFeeLabel, Text(bandwidthFeeString)), - const SizedBox(height: 8), - _buildRow( - bandwidthSourceLabel, - Text( - bandwidthSource, - textAlign: TextAlign.right, - style: secondaryValueStyle, + return Card( + margin: EdgeInsets.zero, + child: Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + title: Text( + LocaleKeys.withdrawNetworkDetails.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 8), - _buildRow(energyUsedLabel, Text('${fee.energyUsed}')), - const SizedBox(height: 8), - _buildRow(energyFeeLabel, Text(energyFeeString)), - const SizedBox(height: 8), - _buildRow( - energySourceLabel, - Text( - energySource, - textAlign: TextAlign.right, - style: secondaryValueStyle, - ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(chargeSummary, style: theme.textTheme.bodySmall), ), - const SizedBox(height: 8), - _buildRow( - feeSummaryLabel, - Text( - chargeSummary, - textAlign: TextAlign.right, - style: secondaryValueStyle, + children: [ + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronBandwidthUsed.tr(), + value: '${fee.bandwidthUsed}', ), - ), - ], - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(bandwidthUsedLabel, style: labelStyle), - const SizedBox(height: 4), - Text('${fee.bandwidthUsed}'), - const SizedBox(height: 8), - Text(bandwidthFeeLabel, style: labelStyle), - const SizedBox(height: 4), - Text(bandwidthFeeString), - const SizedBox(height: 8), - Text(bandwidthSourceLabel, style: labelStyle), - const SizedBox(height: 4), - Text(bandwidthSource, style: secondaryValueStyle), - const SizedBox(height: 8), - Text(energyUsedLabel, style: labelStyle), - const SizedBox(height: 4), - Text('${fee.energyUsed}'), - const SizedBox(height: 8), - Text(energyFeeLabel, style: labelStyle), - const SizedBox(height: 4), - Text(energyFeeString), - const SizedBox(height: 8), - Text(energySourceLabel, style: labelStyle), - const SizedBox(height: 4), - Text(energySource, style: secondaryValueStyle), - const SizedBox(height: 8), - Text(feeSummaryLabel, style: labelStyle), - const SizedBox(height: 4), - Text(chargeSummary, style: secondaryValueStyle), - ], - ); - } - - String _formatDecimal(Decimal value, {int precision = 8}) { - return value.toStringAsFixed(precision).replaceAll(RegExp(r'\.?0+$'), ''); - } - - Widget _buildRow(String label, Widget value) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 2, child: Text(label)), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: Align(alignment: Alignment.centerRight, child: value), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronBandwidthFee.tr(), + value: '${_formatDecimal(fee.bandwidthFee)} ${fee.coin}', + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronBandwidthSource.tr(), + value: bandwidthSource, + valueStyle: theme.textTheme.bodySmall, + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronEnergyUsed.tr(), + value: '${fee.energyUsed}', + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronEnergyFee.tr(), + value: '${_formatDecimal(fee.energyFee)} ${fee.coin}', + ), + if (fee.accountCreationFee != null) + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronAccountActivationFee.tr(), + value: '${_formatDecimal(fee.accountCreationFee!)} ${fee.coin}', + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronEnergySource.tr(), + value: energySource, + valueStyle: theme.textTheme.bodySmall, + ), + _buildDetailRow( + context, + label: LocaleKeys.withdrawTronFeeSummary.tr(), + value: chargeSummary, + valueStyle: theme.textTheme.bodySmall, + ), + ], ), - ], + ), ); } } -class WithdrawResultDetails extends StatelessWidget { - final WithdrawalResult result; +class _WithdrawSectionCard extends StatelessWidget { + const _WithdrawSectionCard({required this.child}); - const WithdrawResultDetails({required this.result, super.key}); + final Widget child; @override Widget build(BuildContext context) { return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText( - LocaleKeys.transactionHash.tr(), - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 4), - SelectableText(result.txHash), - // Add more result details as needed - ], - ), - ), + margin: EdgeInsets.zero, + child: Padding(padding: const EdgeInsets.all(20), child: child), ); } } @@ -738,6 +934,7 @@ class WithdrawFormFillSection extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + final isEditingLocked = state.isSending; final isSourceInputEnabled = // Enabled if the asset has multiple source addresses or if there is // no selected address and pubkeys are available. @@ -748,120 +945,129 @@ class WithdrawFormFillSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SourceAddressField( - asset: state.asset, - pubkeys: state.pubkeys, - selectedAddress: state.selectedSourceAddress, - isLoading: state.pubkeys?.isEmpty ?? true, - onChanged: isSourceInputEnabled - ? (address) => address == null - ? 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), - ), - errorText: state.recipientAddressError == null - ? null - : () => state.recipientAddressError?.message, - ), - const SizedBox(height: 16), - if (state.asset.protocol is TendermintProtocol) ...[ - const IbcTransferField(), - if (state.isIbcTransfer) ...[ - const SizedBox(height: 16), - const IbcChannelField(), - ], - const SizedBox(height: 16), - ], - WithdrawAmountField( - asset: state.asset, - amount: state.amount, - isMaxAmount: state.isMaxAmount, - onChanged: (value) => context.read().add( - WithdrawFormAmountChanged(value), - ), - onMaxToggled: (value) => context.read().add( - WithdrawFormMaxAmountEnabled(value), - ), - amountError: state.amountError?.message, - ), - if (state.isPriorityFeeSupported) ...[ - const SizedBox(height: 16), - WithdrawalPrioritySelector( - feeOptions: state.feeOptions, - selectedPriority: state.selectedFeePriority, - onPriorityChanged: (priority) { - context.read().add( - WithdrawFormFeePriorityChanged(priority), - ); - }, - onCustomFeeSelected: () { - context.read().add( - const WithdrawFormCustomFeeEnabled(true), - ); - }, - ), - ] else if (state.isCustomFeeSupported) ...[ - const SizedBox(height: 16), - Row( + IgnorePointer( + key: const Key('withdraw-form-fill-input-lock'), + ignoring: isEditingLocked, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Checkbox( - value: state.isCustomFee, - onChanged: (enabled) => context + SourceAddressField( + asset: state.asset, + pubkeys: state.pubkeys, + selectedAddress: state.selectedSourceAddress, + isLoading: state.pubkeys?.isEmpty ?? true, + onChanged: isSourceInputEnabled + ? (address) => address == null + ? 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(WithdrawFormCustomFeeEnabled(enabled ?? false)), + .add(WithdrawFormRecipientChanged(value)), + errorText: state.recipientAddressError == null + ? null + : () => state.recipientAddressError?.message, ), - Text(LocaleKeys.customNetworkFee.tr()), - ], - ), - ], - if (state.isCustomFeeSupported && - state.isCustomFee && - state.customFee != null) ...[ - const SizedBox(height: 8), - FeeInfoInput( - asset: state.asset, - selectedFee: state.customFee!, - isCustomFee: true, // indicates user can edit it - onFeeSelected: (newFee) { - context.read().add( - WithdrawFormCustomFeeChanged(newFee!), - ); - }, - ), - // If the bloc has an error for custom fees: - if (state.customFeeError != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - state.customFeeError!.message, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 12, + const SizedBox(height: 16), + if (state.asset.protocol is TendermintProtocol) ...[ + const IbcTransferField(), + if (state.isIbcTransfer) ...[ + const SizedBox(height: 16), + const IbcChannelField(), + ], + const SizedBox(height: 16), + ], + WithdrawAmountField( + asset: state.asset, + amount: state.amount, + isMaxAmount: state.isMaxAmount, + onChanged: (value) => context.read().add( + WithdrawFormAmountChanged(value), ), + onMaxToggled: (value) => context + .read() + .add(WithdrawFormMaxAmountEnabled(value)), + amountError: state.amountError?.message, ), - ), - ], - const SizedBox(height: 16), - if (_isMemoSupportedProtocol(state.asset)) ...[ - WithdrawMemoField( - memo: state.memo, - onChanged: (value) => context.read().add( - WithdrawFormMemoChanged(value), - ), + if (state.isPriorityFeeSupported) ...[ + const SizedBox(height: 16), + WithdrawalPrioritySelector( + feeOptions: state.feeOptions, + selectedPriority: state.selectedFeePriority, + onPriorityChanged: (priority) { + context.read().add( + WithdrawFormFeePriorityChanged(priority), + ); + }, + onCustomFeeSelected: () { + context.read().add( + const WithdrawFormCustomFeeEnabled(true), + ); + }, + ), + ] else if (state.isCustomFeeSupported) ...[ + const SizedBox(height: 16), + Row( + children: [ + Checkbox( + value: state.isCustomFee, + onChanged: (enabled) => + context.read().add( + WithdrawFormCustomFeeEnabled(enabled ?? false), + ), + ), + Text(LocaleKeys.customNetworkFee.tr()), + ], + ), + ], + if (state.isCustomFeeSupported && + state.isCustomFee && + state.customFee != null) ...[ + const SizedBox(height: 8), + FeeInfoInput( + asset: state.asset, + selectedFee: state.customFee!, + isCustomFee: true, // indicates user can edit it + onFeeSelected: (newFee) { + context.read().add( + WithdrawFormCustomFeeChanged(newFee!), + ); + }, + ), + if (state.customFeeError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + state.customFeeError!.message, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + ], + const SizedBox(height: 16), + if (_isMemoSupportedProtocol(state.asset)) ...[ + WithdrawMemoField( + memo: state.memo, + onChanged: (value) => context + .read() + .add(WithdrawFormMemoChanged(value)), + ), + ], + ], ), - ], + ), const SizedBox(height: 24), // TODO! Refactor to use Formz and replace with the appropriate // error state value. @@ -908,6 +1114,154 @@ class WithdrawFormFillSection extends StatelessWidget { class WithdrawFormConfirmSection extends StatelessWidget { const WithdrawFormConfirmSection({super.key}); + Color _warningBackground(BuildContext context) { + final theme = Theme.of(context); + return Colors.amber.withValues( + alpha: theme.brightness == Brightness.dark ? 0.22 : 0.16, + ); + } + + Color _warningForeground(BuildContext context) { + final theme = Theme.of(context); + return theme.brightness == Brightness.dark + ? Colors.amber.shade200 + : Colors.amber.shade900; + } + + Widget? _buildStatusBanner(BuildContext context, WithdrawFormState state) { + if (!state.isTronAsset && + !state.isPreviewRefreshing && + state.confirmStepError == null) { + return null; + } + + final theme = Theme.of(context); + late final Color backgroundColor; + late final Color foregroundColor; + late final IconData icon; + late final String message; + final showSpinner = state.isPreviewRefreshing; + + if (state.isPreviewRefreshing) { + backgroundColor = theme.colorScheme.secondaryContainer; + foregroundColor = theme.colorScheme.onSecondaryContainer; + icon = Icons.refresh_rounded; + message = LocaleKeys.withdrawPreviewRefreshing.tr(); + } else if (state.confirmStepError != null || state.isPreviewExpired) { + backgroundColor = theme.colorScheme.errorContainer; + foregroundColor = theme.colorScheme.onErrorContainer; + icon = Icons.warning_amber_rounded; + message = + state.confirmStepError?.message ?? + LocaleKeys.withdrawTronPreviewExpired.tr(); + } else if (state.previewSecondsRemaining != null) { + final isExpiringSoon = state.previewSecondsRemaining! <= 10; + backgroundColor = isExpiringSoon + ? _warningBackground(context) + : theme.colorScheme.primaryContainer; + foregroundColor = isExpiringSoon + ? _warningForeground(context) + : theme.colorScheme.onPrimaryContainer; + icon = Icons.schedule_rounded; + message = LocaleKeys.withdrawPreviewExpiresIn.tr( + args: [state.previewSecondsRemaining.toString()], + ); + } else { + return null; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showSpinner) + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: foregroundColor, + ), + ) + else + Icon(icon, color: foregroundColor), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildActions( + BuildContext context, { + required WithdrawFormState state, + required bool hasExpiredPreviewAction, + required bool isSubmitDisabled, + }) { + final backButton = OutlinedButton( + onPressed: state.isSending || state.isPreviewRefreshing + ? null + : () => context.read().add( + const WithdrawFormStepReverted(), + ), + child: Text(LocaleKeys.back.tr()), + ); + final primaryButton = FilledButton( + onPressed: hasExpiredPreviewAction + ? () { + context.read().add( + const WithdrawFormTronPreviewRefreshRequested(), + ); + } + : isSubmitDisabled + ? null + : () { + context.read().add( + const WithdrawFormSubmitted(), + ); + }, + child: state.isSending || state.isPreviewRefreshing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + hasExpiredPreviewAction + ? LocaleKeys.withdrawTronPreviewRegenerate.tr() + : LocaleKeys.send.tr(), + ), + ); + + if (isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [primaryButton, const SizedBox(height: 12), backButton], + ); + } + + return Row( + children: [ + Expanded(child: backButton), + const SizedBox(width: 16), + Expanded(child: primaryButton), + ], + ); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -916,41 +1270,32 @@ class WithdrawFormConfirmSection extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } + final hasExpiredPreviewAction = + state.isTronAsset && + !state.isPreviewRefreshing && + (state.isPreviewExpired || state.hasConfirmStepError); + final isSubmitDisabled = + state.isSending || + state.isPreviewRefreshing || + (state.isTronAsset && + (state.previewSecondsRemaining == null || + state.previewSecondsRemaining == 0)); + final statusBanner = _buildStatusBanner(context, state); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - WithdrawPreviewDetails(preview: state.preview!), + WithdrawPreviewDetails(state: state), + if (statusBanner != null) ...[ + const SizedBox(height: 16), + statusBanner, + ], const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => context.read().add( - const WithdrawFormCancelled(), - ), - child: Text(LocaleKeys.back.tr()), - ), - ), - const SizedBox(width: 16), - Expanded( - child: FilledButton( - onPressed: state.isSending - ? null - : () { - context.read().add( - const WithdrawFormSubmitted(), - ); - }, - child: state.isSending - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(LocaleKeys.send.tr()), - ), - ), - ], + _buildActions( + context, + state: state, + hasExpiredPreviewAction: hasExpiredPreviewAction, + isSubmitDisabled: isSubmitDisabled, ), ], ); @@ -968,29 +1313,13 @@ class WithdrawFormSuccessSection extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - // Build a temporary Transaction model matching history view expectations final result = state.result!; - final tx = Transaction( - id: result.txHash, - internalId: result.txHash, - assetId: state.asset.id, - balanceChanges: result.balanceChanges, - // Show as unconfirmed initially - timestamp: DateTime.fromMillisecondsSinceEpoch(0), - confirmations: 0, - blockHeight: 0, - from: state.selectedSourceAddress != null - ? [state.selectedSourceAddress!.address] - : [], - to: [result.toAddress], - txHash: result.txHash, - fee: result.fee, - memo: state.memo, - ); - return TransactionDetails( - transaction: tx, - coin: state.asset.toCoin(), + return WithdrawSuccessReceipt( + asset: state.asset, + result: result, + sourceAddress: state.selectedSourceAddress?.address, + memo: state.memo, onClose: onDone, ); }, @@ -998,75 +1327,271 @@ class WithdrawFormSuccessSection extends StatelessWidget { } } -class WithdrawResultCard extends StatelessWidget { - final WithdrawalResult result; - final Asset asset; - - const WithdrawResultCard({ - required this.result, +class WithdrawSuccessReceipt extends StatelessWidget { + const WithdrawSuccessReceipt({ required this.asset, + required this.result, + required this.onClose, + this.sourceAddress, + this.memo, super.key, }); - @override - Widget build(BuildContext context) { - final maybeTxEplorer = asset.protocol.explorerTxUrl(result.txHash); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHashSection(context), - const Divider(height: 32), - _buildNetworkSection(context), - if (maybeTxEplorer != null) ...[ - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => openUrl(maybeTxEplorer), - icon: const Icon(Icons.open_in_new), - label: Text(LocaleKeys.viewOnExplorer.tr()), - ), - ], - ], - ), - ), + final Asset asset; + final WithdrawalResult result; + final String? sourceAddress; + final String? memo; + final VoidCallback onClose; + + Widget _buildActions(BuildContext context, Uri? explorerUrl) { + final doneButton = explorerUrl == null + ? FilledButton(onPressed: onClose, child: Text(LocaleKeys.done.tr())) + : OutlinedButton(onPressed: onClose, child: Text(LocaleKeys.done.tr())); + + if (explorerUrl == null) { + return SizedBox(width: double.infinity, child: doneButton); + } + + final explorerButton = FilledButton.icon( + onPressed: () => openUrl(explorerUrl), + icon: const Icon(Icons.open_in_new_rounded), + label: Text(LocaleKeys.viewOnExplorer.tr()), ); - } - Widget _buildHashSection(BuildContext context) { - final theme = Theme.of(context); + if (isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [explorerButton, const SizedBox(height: 12), doneButton], + ); + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Row( children: [ - Text( - LocaleKeys.transactionHash.tr(), - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - SelectableText( - result.txHash, - style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'Mono'), - ), + Expanded(child: explorerButton), + const SizedBox(width: 16), + Expanded(child: doneButton), ], ); } - Widget _buildNetworkSection(BuildContext context) { + Widget _buildDetailItem( + BuildContext context, { + required String label, + required Widget child, + }) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.72), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + child, + ], + ), + ); + } + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); + final explorerUrl = asset.protocol.explorerTxUrl(result.txHash); + final feeAssetId = _resolveFeeAssetId(context, asset, result.fee); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - 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), - ], + _WithdrawSectionCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.check_circle_rounded, + size: 64, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + LocaleKeys.successPageHeadline.tr(), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + AssetLogo.ofId(asset.id, size: 52), + const SizedBox(height: 12), + Center( + child: AssetAmountWithFiat( + assetId: asset.id, + amount: result.amount, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + isAutoScrollEnabled: false, + ), + ), + const SizedBox(height: 8), + Text( + asset.id.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: Text( + LocaleKeys.recipientAddress.tr(), + style: theme.textTheme.labelLarge?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues( + alpha: 0.72, + ), + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: CopiedTextV2( + copiedValue: result.toAddress, + fontSize: 13, + iconSize: 14, + backgroundColor: theme.colorScheme.primary.withValues( + alpha: 0.08, + ), + textColor: theme.textTheme.bodyLarge?.color, + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Chip( + padding: EdgeInsets.zero, + avatar: Icon( + Icons.schedule_rounded, + size: 18, + color: theme.colorScheme.onPrimaryContainer, + ), + label: Text( + LocaleKeys.withdrawAwaitingConfirmations.tr(), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + backgroundColor: theme.colorScheme.primaryContainer, + side: BorderSide.none, + ), + ), + const SizedBox(height: 24), + _buildActions(context, explorerUrl), + ], + ), + ), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + title: Text( + LocaleKeys.technicalDetails.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + children: [ + _buildDetailItem( + context, + label: LocaleKeys.transactionHash.tr(), + child: CopiedText( + copiedValue: result.txHash, + isTruncated: true, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + if (sourceAddress?.isNotEmpty ?? false) + _buildDetailItem( + context, + label: LocaleKeys.from.tr(), + child: CopiedText( + copiedValue: sourceAddress!, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + _buildDetailItem( + context, + label: LocaleKeys.to.tr(), + child: CopiedText( + copiedValue: result.toAddress, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + _buildDetailItem( + context, + label: LocaleKeys.fee.tr(), + child: AssetAmountWithFiat( + assetId: feeAssetId, + amount: result.fee.totalFee, + isAutoScrollEnabled: false, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (memo?.isNotEmpty ?? false) + _buildDetailItem( + context, + label: LocaleKeys.memo.tr(), + child: SelectableText( + memo!, + style: theme.textTheme.bodyLarge, + ), + ), + _buildDetailItem( + context, + label: LocaleKeys.network.tr(), + child: Row( + children: [ + AssetLogo.ofId(asset.id, size: 28), + const SizedBox(width: 10), + Text( + asset.id.name, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), ), ], ); diff --git a/sdk b/sdk index 877bb57fe7..b8555f1dd4 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit 877bb57fe7ca3e528e60eab2688062dc1d631ade +Subproject commit b8555f1dd4dd19e7d04b2cdf7248ddfdbf36a1bc diff --git a/test_integration/tests/wallets_tests/test_coin_details_core.dart b/test_integration/tests/wallets_tests/test_coin_details_core.dart new file mode 100644 index 0000000000..a742150369 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_coin_details_core.dart @@ -0,0 +1,63 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/widget_tester_action_extensions.dart'; +import '../../common/widget_tester_find_extension.dart'; +import '../../common/widget_tester_pump_extension.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; +import 'wallet_tools.dart'; + +Future _activateMarty(WidgetTester tester) async { + final coinsList = find.byKeyName('wallet-page-scroll-view'); + final martyCoinItem = find.byKeyName('coins-manager-list-item-marty'); + final martyCoinActive = find.byKeyName('active-coin-item-marty'); + + await addAsset(tester, asset: martyCoinItem, search: 'marty'); + await tester.pumpUntilVisible( + martyCoinActive, + timeout: const Duration(seconds: 30), + throwOnError: false, + ); + await tester.dragUntilVisible( + martyCoinActive, + coinsList, + const Offset(0, -50), + ); + await tester.tapAndPump(martyCoinActive); + await tester.pumpAndSettle(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('coin details core sections render after opening active coin', ( + tester, + ) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await _activateMarty(tester); + + expect(find.byKeyName('coin-details-send-button'), findsOneWidget); + expect(find.byKeyName('coin-details-receive-button'), findsOneWidget); + expect(find.byKeyName('coin-details-balance'), findsOneWidget); + + // Core navigation sanity: open send flow and return. + await tester.tapAndPump(find.byKeyName('coin-details-send-button')); + expect(find.byKeyName('withdraw-recipient-address-input'), findsOneWidget); + await tester.tapAndPump(find.byKey(const Key('back-button'))); + + // Receive flow can open and return cleanly. + await tester.tapAndPump(find.byKeyName('coin-details-receive-button')); + expect(find.byKeyName('coin-details-address-field'), findsOneWidget); + await tester.tapAndPump(find.byKey(const Key('back-button'))); + }); +} diff --git a/test_integration/tests/wallets_tests/test_coin_details_rewards.dart b/test_integration/tests/wallets_tests/test_coin_details_rewards.dart new file mode 100644 index 0000000000..7e6b4b4ba6 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_coin_details_rewards.dart @@ -0,0 +1,53 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/widget_tester_action_extensions.dart'; +import '../../common/widget_tester_find_extension.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('rewards flow opens and handles no rewards gracefully', ( + tester, + ) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + + final kmdCoinActive = find.byKeyName('active-coin-item-kmd'); + if (kmdCoinActive.evaluate().isEmpty) { + print('Skipping rewards check: KMD is not active in this environment.'); + return; + } + + await tester.tapAndPump(kmdCoinActive); + await tester.pumpAndSettle(); + + final rewardsButtonText = find.text('getRewards'); + if (rewardsButtonText.evaluate().isEmpty) { + print( + 'Skipping rewards check: rewards button unavailable for current wallet mode.', + ); + return; + } + + await tester.tapAndPump(rewardsButtonText.first); + await tester.pumpAndSettle(); + + // Accept both "no rewards" and claimable states. + final noRewards = find.text('noRewards'); + final claimButton = find.byKeyName('reward-claim-button'); + expect( + noRewards.evaluate().isNotEmpty || claimButton.evaluate().isNotEmpty, + isTrue, + ); + }); +} diff --git a/test_integration/tests/wallets_tests/test_withdraw.dart b/test_integration/tests/wallets_tests/test_withdraw.dart index 0edb11b299..471de796a2 100644 --- a/test_integration/tests/wallets_tests/test_withdraw.dart +++ b/test_integration/tests/wallets_tests/test_withdraw.dart @@ -20,6 +20,9 @@ Future testWithdraw(WidgetTester tester) async { Finder martyCoinItem = await _activateMarty(tester); print('🔍 WITHDRAW TEST: Marty coin activated'); + await _assertCoinDetailsCoreSections(tester); + print('🔍 WITHDRAW TEST: Core coin details sections verified'); + await _testCopyAddressButton(tester); print('🔍 WITHDRAW TEST: Copy address button test completed'); @@ -41,6 +44,18 @@ Future testWithdraw(WidgetTester tester) async { } } +Future _assertCoinDetailsCoreSections(WidgetTester tester) async { + expect(find.byKeyName('coin-details-balance'), findsOneWidget); + expect(find.byKeyName('coin-details-send-button'), findsOneWidget); + expect(find.byKeyName('coin-details-receive-button'), findsOneWidget); + + // Some assets expose faucet; if it exists, at least verify the button can be found. + final faucetFinder = find.byKeyName('coin-details-faucet-button'); + if (faucetFinder.evaluate().isNotEmpty) { + expect(faucetFinder, findsWidgets); + } +} + Future _activateMarty(WidgetTester tester) async { print('🔍 ACTIVATE MARTY: Starting activation process'); @@ -60,7 +75,10 @@ Future _activateMarty(WidgetTester tester) async { print('🔍 ACTIVATE MARTY: Waited for coin to become visible'); await tester.dragUntilVisible( - martyCoinActive, coinsList, const Offset(0, -50)); + martyCoinActive, + coinsList, + const Offset(0, -50), + ); print('🔍 ACTIVATE MARTY: Scrolled to coin'); await tester.tapAndPump(martyCoinActive); @@ -75,12 +93,8 @@ Future _activateMarty(WidgetTester tester) async { Future _testCopyAddressButton(WidgetTester tester) async { print('🔍 COPY ADDRESS: Starting copy address test'); - final Finder coinBalance = find.byKey( - const Key('coin-details-balance'), - ); - final Finder exitButton = find.byKey( - const Key('back-button'), - ); + final Finder coinBalance = find.byKey(const Key('coin-details-balance')); + final Finder exitButton = find.byKey(const Key('back-button')); final Finder receiveButton = find.byKey( const Key('coin-details-receive-button'), ); @@ -167,25 +181,21 @@ Future _sendAmountToAddress( void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets( - 'Run withdraw tests:', - (WidgetTester tester) async { - print('🔍 MAIN: Starting withdraw test suite'); - tester.testTextInput.register(); - await app.main(); - await tester.pumpAndSettle(); - - print('🔍 MAIN: Accepting alpha warning'); - await acceptAlphaWarning(tester); - - await restoreWalletToTest(tester); - print('🔍 MAIN: Wallet restored'); - - await testWithdraw(tester); - await tester.pumpAndSettle(); - - print('🔍 MAIN: Withdraw tests completed successfully'); - }, - semanticsEnabled: false, - ); + testWidgets('Run withdraw tests:', (WidgetTester tester) async { + print('🔍 MAIN: Starting withdraw test suite'); + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + print('🔍 MAIN: Accepting alpha warning'); + await acceptAlphaWarning(tester); + + await restoreWalletToTest(tester); + print('🔍 MAIN: Wallet restored'); + + await testWithdraw(tester); + await tester.pumpAndSettle(); + + print('🔍 MAIN: Withdraw tests completed successfully'); + }, semanticsEnabled: false); } diff --git a/test_units/main.dart b/test_units/main.dart index abf24bb69a..394d289c63 100644 --- a/test_units/main.dart +++ b/test_units/main.dart @@ -1,4 +1,4 @@ -import 'package:test/test.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'tests/encryption/encrypt_data_test.dart'; import 'tests/formatter/compare_dex_to_cex_tests.dart'; @@ -28,6 +28,16 @@ import 'tests/system_health/ntp_time_provider_test.dart'; import 'tests/system_health/system_clock_repository_test.dart'; import 'tests/system_health/time_provider_registry_test.dart'; import 'tests/balance_utils/compute_wallet_total_usd_test.dart'; +import 'tests/balance_utils/coins_state_usd_conversion_test.dart'; +import 'tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart'; +import 'tests/wallet/coin_details/coin_details_balance_content_test.dart'; +import 'tests/wallet/coin_details/kmd_rewards_logic_test.dart'; +import 'tests/wallet/coin_details/receive_address_faucet_widget_test.dart'; +import 'tests/wallet/coin_details/rewards_widget_test.dart'; +import 'tests/wallet/coin_details/transaction_details_logic_test.dart'; +import 'tests/wallet/coin_details/transaction_views_widget_test.dart'; +import 'tests/wallet/coin_details/withdraw_form_bloc_test.dart'; +import 'tests/wallet/coin_details/withdraw_form_fill_section_test.dart'; import 'tests/utils/convert_double_to_string_test.dart'; import 'tests/utils/convert_fract_rat_test.dart'; import 'tests/utils/double_to_string_test.dart'; @@ -66,6 +76,7 @@ void main() { group('Utils:', () { testComputeWalletTotalUsd(); + testCoinsStateUsdConversion(); // TODO: re-enable or migrate to the SDK testUsdBalanceFormatter(); testGetFiatAmount(); @@ -103,4 +114,16 @@ void main() { testNtpTimeProvider(); testTimeProviderRegistry(); }); + + group('CoinDetails:', () { + testWithdrawFormBloc(); + testCoinDetailsBalanceConfirmationController(); + testCoinDetailsBalanceContent(); + testWithdrawFormFillSection(); + testTransactionDetailsLogic(); + testKmdRewardsLogic(); + testRewardsWidgets(); + testTransactionViewsWidgets(); + testReceiveAddressFaucetWidgets(); + }); } diff --git a/test_units/tests/balance_utils/coins_state_usd_conversion_test.dart b/test_units/tests/balance_utils/coins_state_usd_conversion_test.dart new file mode 100644 index 0000000000..2a37bd5139 --- /dev/null +++ b/test_units/tests/balance_utils/coins_state_usd_conversion_test.dart @@ -0,0 +1,33 @@ +import 'package:test/test.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; + +import '../utils/test_util.dart'; + +void main() { + testCoinsStateUsdConversion(); +} + +void testCoinsStateUsdConversion() { + final coin = setCoin(coinAbbr: 'TRX', usdPrice: 4.0); + final state = CoinsState( + coins: {'TRX': coin}, + walletCoins: {'TRX': coin}, + pubkeys: const {}, + prices: {'TRX': coin.usdPrice!}, + ); + + test('typed USD conversion handles numeric values safely', () { + expect(state.getUsdPriceForAmount(1.1, 'TRX'), closeTo(4.4, 1e-12)); + }); + + test( + 'legacy string conversion returns null for display-formatted values', + () { + expect(state.getUsdPriceByAmount('1.1 TRX', 'TRX'), isNull); + }, + ); + + test('legacy string conversion still supports numeric strings', () { + expect(state.getUsdPriceByAmount('1.1', 'TRX'), closeTo(4.4, 1e-12)); + }); +} diff --git a/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart b/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart index ed7e1594dc..7b84f2158c 100644 --- a/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart +++ b/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart @@ -199,7 +199,14 @@ class _FakeCoinsRepo implements CoinsRepo { } @override - double? getUsdPriceByAmount(String amount, String coinAbbr) => 12.5; + double? getUsdPriceForAmount(num amount, String coinAbbr) => 12.5; + + @override + double? getUsdPriceByAmount(String amount, String coinAbbr) { + final parsed = double.tryParse(amount); + if (parsed == null) return null; + return getUsdPriceForAmount(parsed, coinAbbr); + } @override Future isAssetActivated( diff --git a/test_units/tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart b/test_units/tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart new file mode 100644 index 0000000000..5ea1c5baac --- /dev/null +++ b/test_units/tests/wallet/coin_details/coin_details_balance_confirmation_controller_test.dart @@ -0,0 +1,140 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_balance_confirmation_controller.dart'; + +BalanceInfo _balance(int amount) { + final value = Decimal.fromInt(amount); + return BalanceInfo(total: value, spendable: value, unspendable: Decimal.zero); +} + +Future _drainAsyncQueue([int iterations = 10]) async { + for (var i = 0; i < iterations; i++) { + await Future.delayed(Duration.zero); + } +} + +void testCoinDetailsBalanceConfirmationController() { + group('CoinDetailsBalanceConfirmationController', () { + test( + 'keeps cached startup balance unconfirmed until bootstrap succeeds', + () async { + int fetchCalls = 0; + final controller = CoinDetailsBalanceConfirmationController( + initialBalance: _balance(0), + fetchConfirmedBalance: () async { + fetchCalls += 1; + return _balance(12); + }, + ); + + expect(controller.isConfirmed, isFalse); + expect(controller.latestBalance?.spendable, Decimal.zero); + + await controller.bootstrap(); + + expect(fetchCalls, 1); + expect(controller.isConfirmed, isTrue); + expect(controller.latestBalance?.spendable, Decimal.fromInt(12)); + }, + ); + + test( + 'pre-bootstrap stream update stays unconfirmed even when cached value is non-zero', + () { + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async => _balance(0), + ); + + expect(controller.isConfirmed, isFalse); + + controller.onStreamBalance(_balance(7)); + + expect(controller.isConfirmed, isFalse); + expect(controller.latestBalance?.spendable, Decimal.fromInt(7)); + }, + ); + + test( + 'stream update confirms balance after a bootstrap attempt completes', + () async { + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + throw StateError('temporary bootstrap failure'); + }, + ); + + expect(controller.isConfirmed, isFalse); + + await controller.bootstrap(); + controller.onStreamBalance(_balance(7)); + + expect(controller.isConfirmed, isTrue); + expect(controller.latestBalance?.spendable, Decimal.fromInt(7)); + }, + ); + + test('bootstrap failures trigger bounded startup retries', () async { + int attempts = 0; + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + attempts += 1; + throw StateError('temporary startup issue'); + }, + maxStartupRetries: 2, + retryBackoffBase: Duration.zero, + ); + + await controller.bootstrap(); + await _drainAsyncQueue(); + + expect(attempts, 3, reason: 'Initial bootstrap + 2 retries'); + expect(controller.startupRetryAttempts, 2); + expect(controller.isConfirmed, isFalse); + }); + + test('startup stream errors trigger bounded bootstrap retries', () async { + int attempts = 0; + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + attempts += 1; + throw StateError('temporary startup issue'); + }, + maxStartupRetries: 2, + retryBackoffBase: Duration.zero, + ); + + await controller.bootstrap(); + await _drainAsyncQueue(); + await controller.onStartupStreamError(); + await _drainAsyncQueue(); + + expect(attempts, 3, reason: 'Initial bootstrap + 2 retries'); + expect(controller.startupRetryAttempts, 2); + expect(controller.isConfirmed, isFalse); + }); + + test('dispose turns startup paths into no-ops', () async { + int fetchCalls = 0; + final controller = CoinDetailsBalanceConfirmationController( + fetchConfirmedBalance: () async { + fetchCalls += 1; + return _balance(1); + }, + ); + + controller.dispose(); + await controller.bootstrap(); + await controller.onStartupStreamError(); + controller.onStreamBalance(_balance(9)); + + expect(fetchCalls, 0); + expect(controller.isConfirmed, isFalse); + expect(controller.latestBalance, isNull); + }); + }); +} + +void main() { + testCoinDetailsBalanceConfirmationController(); +} diff --git a/test_units/tests/wallet/coin_details/coin_details_balance_content_test.dart b/test_units/tests/wallet/coin_details/coin_details_balance_content_test.dart new file mode 100644 index 0000000000..e9b366f6c4 --- /dev/null +++ b/test_units/tests/wallet/coin_details/coin_details_balance_content_test.dart @@ -0,0 +1,67 @@ +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'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info.dart'; + +import '../../utils/test_util.dart'; + +BalanceInfo _balance(int amount) { + final value = Decimal.fromInt(amount); + return BalanceInfo(total: value, spendable: value, unspendable: Decimal.zero); +} + +Widget _buildTestWidget({ + required bool isConfirmed, + required BalanceInfo? latestBalance, +}) { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(1280, 800)), + child: Builder( + builder: (context) { + updateScreenType(context); + return Scaffold( + body: CoinDetailsBalanceContent( + coin: setCoin(coinAbbr: 'TRX'), + hideBalances: false, + isConfirmed: isConfirmed, + latestBalance: latestBalance, + fiatBalance: const Text('fiat-probe'), + ), + ); + }, + ), + ), + ); +} + +void testCoinDetailsBalanceContent() { + group('CoinDetailsBalanceContent', () { + testWidgets( + 'desktop ghost state suppresses fiat balance until confirmation', + (tester) async { + await tester.pumpWidget( + _buildTestWidget(isConfirmed: false, latestBalance: _balance(5)), + ); + + expect(find.byKey(const Key('coin-details-balance')), findsOneWidget); + expect(find.text('fiat-probe'), findsNothing); + + await tester.pumpWidget( + _buildTestWidget(isConfirmed: true, latestBalance: _balance(5)), + ); + + expect(find.text('fiat-probe'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 12)); + }, + ); + }); +} + +void main() { + testCoinDetailsBalanceContent(); +} diff --git a/test_units/tests/wallet/coin_details/coin_details_test_harness.dart b/test_units/tests/wallet/coin_details/coin_details_test_harness.dart new file mode 100644 index 0000000000..7b1d662a13 --- /dev/null +++ b/test_units/tests/wallet/coin_details/coin_details_test_harness.dart @@ -0,0 +1,46 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; + +import '../../utils/test_util.dart'; + +Coin buildTestCoin({String abbr = 'KMD', CoinType type = CoinType.smartChain}) { + final coin = setCoin(coinAbbr: abbr); + return coin.copyWith(type: type); +} + +Transaction buildTestTransaction({ + required AssetId assetId, + String txHash = 'tx-hash-1', + Decimal? netChange, + int confirmations = 0, + int blockHeight = 0, + FeeInfo? fee, + String? memo, +}) { + return Transaction( + id: 'tx-id-1', + internalId: 'tx-internal-1', + assetId: assetId, + balanceChanges: BalanceChanges( + netChange: netChange ?? Decimal.parse('-1.0'), + receivedByMe: Decimal.zero, + spentByMe: Decimal.one, + totalAmount: Decimal.one, + ), + timestamp: DateTime.utc(2025, 1, 1, 0, 0, 0), + confirmations: confirmations, + blockHeight: blockHeight, + from: const ['from-address'], + to: const ['to-address'], + txHash: txHash, + fee: fee, + memo: memo, + ); +} + +Widget wrapWithMaterial(Widget child) { + return MaterialApp(home: Scaffold(body: child)); +} diff --git a/test_units/tests/wallet/coin_details/kmd_rewards_logic_test.dart b/test_units/tests/wallet/coin_details/kmd_rewards_logic_test.dart new file mode 100644 index 0000000000..fe66eaa7f6 --- /dev/null +++ b/test_units/tests/wallet/coin_details/kmd_rewards_logic_test.dart @@ -0,0 +1,273 @@ +// ignore_for_file: use_build_context_synchronously + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/blocs/kmd_rewards_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_rewards_info_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; +import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; + +import 'coin_details_test_harness.dart'; + +class _FakeCoinsRepo implements CoinsRepo { + _FakeCoinsRepo({this.coin, this.withdrawResult}); + + final Coin? coin; + final BlocResponse? withdrawResult; + + @override + Coin? getCoin(String _) => coin; + + @override + Future> withdraw( + WithdrawRequest _, + ) async { + return withdrawResult ?? + BlocResponse(error: TextError(error: 'withdraw not configured')); + } + + @override + double? getUsdPriceForAmount(num amount, String coinAbbr) => 0; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMm2Api implements Mm2Api { + _FakeMm2Api({this.rewards, this.sendResponse}); + + final List? rewards; + final SendRawTransactionResponse? sendResponse; + + @override + Future?> getRewardsInfo(KmdRewardsInfoRequest _) async { + if (rewards == null) { + return null; + } + + return { + 'result': rewards! + .map( + (r) => { + 'tx_hash': r.txHash, + 'height': r.height, + 'output_index': r.outputIndex, + 'amount': r.amount, + 'locktime': r.lockTime, + 'accrued_rewards': r.reward == null + ? {'NotAccruedReason': 'OneHourNotPassedYet'} + : {'Accrued': '${r.reward!}'}, + 'accrue_start_at': r.accrueStartAt, + 'accrue_stop_at': r.accrueStopAt, + }, + ) + .toList(), + }; + } + + @override + Future sendRawTransaction( + SendRawTransactionRequest _, + ) async { + return sendResponse ?? + SendRawTransactionResponse( + txHash: null, + error: TextError(error: 'broadcast failed'), + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +WithdrawDetails _withdrawDetails({ + String txHex = 'signed-hex', + String myBalanceChange = '1.5', +}) { + return WithdrawDetails( + txHex: txHex, + txHash: 'tx-hash', + from: const ['from'], + to: const ['to'], + totalAmount: '1.5', + spentByMe: '0', + receivedByMe: '1.5', + myBalanceChange: myBalanceChange, + blockHeight: 1, + timestamp: 1, + feeDetails: FeeDetails.empty(), + coin: 'KMD', + internalId: 'internal-1', + ); +} + +void testKmdRewardsLogic() { + group('KmdRewardsBloc', () { + testWidgets('claim returns error when no KMD coin is active', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final bloc = KmdRewardsBloc(_FakeCoinsRepo(coin: null), _FakeMm2Api()); + + final response = await bloc.claim(context); + + expect(response.error, isNotNull); + expect(response.result, isNull); + }); + + testWidgets('claim returns error when withdraw fails', (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse( + error: TextError(error: 'withdraw failed'), + ), + ), + _FakeMm2Api(), + ); + + final response = await bloc.claim(context); + + expect(response.error, isNotNull); + expect(response.result, isNull); + }); + + testWidgets('claim returns error when txHex is missing', (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse(result: _withdrawDetails(txHex: '')), + ), + _FakeMm2Api(), + ); + + final response = await bloc.claim(context); + + expect(response.error, isNotNull); + expect(response.result, isNull); + }); + + testWidgets('claim succeeds when withdraw and broadcast succeed', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse(result: _withdrawDetails()), + ), + _FakeMm2Api(sendResponse: SendRawTransactionResponse(txHash: 'hash-1')), + ); + + final response = await bloc.claim(context); + + expect(response.error, isNull); + expect(response.result, '1.5'); + }); + + test('getInfo returns empty list when API returns no result', () async { + final bloc = KmdRewardsBloc(_FakeCoinsRepo(), _FakeMm2Api(rewards: null)); + + final info = await bloc.getInfo(); + + expect(info, isEmpty); + }); + + testWidgets('getTotal returns parsed total from withdraw response', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + final bloc = KmdRewardsBloc( + _FakeCoinsRepo( + coin: coin, + withdrawResult: BlocResponse( + result: _withdrawDetails(myBalanceChange: '2.25'), + ), + ), + _FakeMm2Api(), + ); + + final total = await bloc.getTotal(context); + + expect(total, 2.25); + }); + }); +} + +void main() { + testKmdRewardsLogic(); +} diff --git a/test_units/tests/wallet/coin_details/receive_address_faucet_widget_test.dart b/test_units/tests/wallet/coin_details/receive_address_faucet_widget_test.dart new file mode 100644 index 0000000000..0283099f43 --- /dev/null +++ b/test_units/tests/wallet/coin_details/receive_address_faucet_widget_test.dart @@ -0,0 +1,100 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/faucet_button/faucet_button_bloc.dart'; +import 'package:web_dex/bloc/faucet_button/faucet_button_event.dart'; +import 'package:web_dex/bloc/faucet_button/faucet_button_state.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/faucet_button.dart'; + +class _FakeFaucetBloc extends Cubit implements FaucetBloc { + _FakeFaucetBloc(super.initialState); + + FaucetEvent? lastEvent; + + @override + void add(FaucetEvent event) { + lastEvent = event; + if (event is FaucetRequested) { + emit(FaucetRequestInProgress(address: event.address)); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +PubkeyInfo _address(String value) { + return PubkeyInfo( + address: value, + derivationPath: "m/44'/141'/0'/0/0", + chain: 'external', + balance: BalanceInfo( + total: Decimal.one, + spendable: Decimal.one, + unspendable: Decimal.zero, + ), + coinTicker: 'KMD', + ); +} + +void testReceiveAddressFaucetWidgets() { + group('Receive/address/faucet widgets', () { + testWidgets('faucet button dispatches request for selected address', ( + tester, + ) async { + final bloc = _FakeFaucetBloc(const FaucetInitial()); + addTearDown(bloc.close); + final address = _address('R-test-address'); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: Scaffold( + body: FaucetButton(coinAbbr: 'KMD', address: address), + ), + ), + ), + ); + + await tester.tap(find.byType(UiPrimaryButton)); + await tester.pump(); + + expect(bloc.lastEvent, isA()); + final event = bloc.lastEvent! as FaucetRequested; + expect(event.coinAbbr, 'KMD'); + expect(event.address, address.address); + }); + + testWidgets('faucet button disabled while request pending', (tester) async { + final address = _address('R-test-address'); + final bloc = _FakeFaucetBloc( + FaucetRequestInProgress(address: address.address), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: Scaffold( + body: FaucetButton(coinAbbr: 'KMD', address: address), + ), + ), + ), + ); + + final button = tester.widget( + find.byType(UiPrimaryButton), + ); + expect(button.onPressed, isNull); + }); + }); +} + +void main() { + testReceiveAddressFaucetWidgets(); +} diff --git a/test_units/tests/wallet/coin_details/rewards_widget_test.dart b/test_units/tests/wallet/coin_details/rewards_widget_test.dart new file mode 100644 index 0000000000..0de3207861 --- /dev/null +++ b/test_units/tests/wallet/coin_details/rewards_widget_test.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.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_repo.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/blocs/kmd_rewards_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart'; +import 'package:web_dex/views/wallet/coin_details/rewards/kmd_rewards_info.dart'; + +import 'coin_details_test_harness.dart'; + +class _FakeCoinsRepo implements CoinsRepo { + _FakeCoinsRepo(); + + @override + double? getUsdPriceForAmount(num amount, String coinAbbr) => 0; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeKmdRewardsBloc implements KmdRewardsBloc { + _FakeKmdRewardsBloc({required this.totalFuture, required this.infoFuture}); + + final Future totalFuture; + final Future> infoFuture; + + @override + Future getTotal(BuildContext context) => totalFuture; + + @override + Future> getInfo() => infoFuture; + + @override + Future> claim(BuildContext context) async => + BlocResponse(result: '0'); + + @override + void dispose() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAuthBloc extends Cubit implements AuthBloc { + _FakeAuthBloc() : super(AuthBlocState.initial()); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _buildWidget({ + required KmdRewardsBloc rewardsBloc, + required CoinsRepo coinsRepo, +}) { + final coin = buildTestCoin(abbr: 'KMD'); + coin.address = 'R-address'; + + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(1600, 1200)), + child: Builder( + builder: (context) { + updateScreenType(context); + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: coinsRepo), + RepositoryProvider.value(value: rewardsBloc), + ], + child: BlocProvider( + create: (_) => _FakeAuthBloc(), + child: Scaffold( + body: KmdRewardsInfo( + coin: coin, + onSuccess: (_, __) {}, + onBackButtonPressed: () {}, + ), + ), + ), + ); + }, + ), + ), + ); +} + +void testRewardsWidgets() { + group('KmdRewardsInfo widget', () { + testWidgets('rewards page shows spinner before data load', (tester) async { + final totalCompleter = Completer(); + final infoCompleter = Completer>(); + final rewardsBloc = _FakeKmdRewardsBloc( + totalFuture: totalCompleter.future, + infoFuture: infoCompleter.future, + ); + + await tester.pumpWidget( + _buildWidget(rewardsBloc: rewardsBloc, coinsRepo: _FakeCoinsRepo()), + ); + + expect(find.byType(UiSpinnerList), findsOneWidget); + }); + + testWidgets('rewards page shows no rewards when empty', (tester) async { + final rewardsBloc = _FakeKmdRewardsBloc( + totalFuture: Future.value(0), + infoFuture: Future>.value(const []), + ); + + await tester.pumpWidget( + _buildWidget(rewardsBloc: rewardsBloc, coinsRepo: _FakeCoinsRepo()), + ); + await tester.pumpAndSettle(); + + expect(find.text('noRewards'), findsOneWidget); + }); + + testWidgets('rewards page renders reward items when present', ( + tester, + ) async { + final rewardsBloc = _FakeKmdRewardsBloc( + totalFuture: Future.value(1.2), + infoFuture: Future>.value([ + KmdRewardItem( + txHash: 'hash-1', + height: 1, + outputIndex: 0, + amount: '10', + lockTime: 1, + reward: 0.1, + accrueStartAt: 1, + accrueStopAt: 2, + ), + ]), + ); + + await tester.pumpWidget( + _buildWidget(rewardsBloc: rewardsBloc, coinsRepo: _FakeCoinsRepo()), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('reward-claim-button')), findsOneWidget); + }); + }); +} + +void main() { + testRewardsWidgets(); +} diff --git a/test_units/tests/wallet/coin_details/transaction_details_logic_test.dart b/test_units/tests/wallet/coin_details/transaction_details_logic_test.dart new file mode 100644 index 0000000000..b42f999073 --- /dev/null +++ b/test_units/tests/wallet/coin_details/transaction_details_logic_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:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; + +import 'coin_details_test_harness.dart'; + +Future _disposeAnimatedWidgets(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 3)); +} + +void testTransactionDetailsLogic() { + group('TransactionDetails logic', () { + testWidgets('confirmations label returns count when confirmed', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + confirmations: 3, + blockHeight: 100, + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('3'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets( + 'confirmations label returns zero when block height present without confirmations', + (tester) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + confirmations: 0, + blockHeight: 10, + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('0'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }, + ); + + testWidgets('confirmations label returns in-progress when pending', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + confirmations: 0, + blockHeight: 0, + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text(LocaleKeys.inProgress), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('block height label returns unknown when block is zero', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, blockHeight: 0); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text(LocaleKeys.unknown), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('balance change formats plus sign for incoming', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + netChange: Decimal.parse('1.25'), + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.textContaining('+'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('balance change formats minus sign for outgoing', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction( + assetId: coin.id, + netChange: Decimal.parse('-1.25'), + ); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.textContaining('-'), findsWidgets); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('balance change renders fiat amount from resolver', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.textContaining(r'($0'), findsWidgets); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('fee section renders em dash when fee is absent', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, fee: null); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('—'), findsOneWidget); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('memo section hides when empty', (tester) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, memo: ''); + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + expect(find.text('${LocaleKeys.memo}: '), findsNothing); + await _disposeAnimatedWidgets(tester); + }); + }); +} + +void main() { + testTransactionDetailsLogic(); +} diff --git a/test_units/tests/wallet/coin_details/transaction_views_widget_test.dart b/test_units/tests/wallet/coin_details/transaction_views_widget_test.dart new file mode 100644 index 0000000000..ebcf3f1144 --- /dev/null +++ b/test_units/tests/wallet/coin_details/transaction_views_widget_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_table.dart'; + +import 'coin_details_test_harness.dart'; + +class _FakeTransactionHistoryBloc extends Cubit + implements TransactionHistoryBloc { + _FakeTransactionHistoryBloc(super.initialState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future _disposeAnimatedWidgets(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 3)); +} + +void testTransactionViewsWidgets() { + group('Transaction views widgets', () { + testWidgets('transaction table shows loading spinner while fetching', ( + tester, + ) async { + final coin = buildTestCoin(type: CoinType.smartChain); + final bloc = _FakeTransactionHistoryBloc( + const TransactionHistoryState( + transactions: [], + loading: true, + error: null, + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: CustomScrollView( + slivers: [ + TransactionTable( + coin: coin, + selectedTransaction: null, + setTransaction: (_) {}, + ), + ], + ), + ), + ), + ); + + expect(find.byType(UiSpinnerList), findsOneWidget); + }); + + testWidgets('transaction table shows empty state without items', ( + tester, + ) async { + final coin = buildTestCoin(type: CoinType.smartChain); + final bloc = _FakeTransactionHistoryBloc( + const TransactionHistoryState.initial(), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: CustomScrollView( + slivers: [ + TransactionTable( + coin: coin, + selectedTransaction: null, + setTransaction: (_) {}, + ), + ], + ), + ), + ), + ); + + expect(find.text(LocaleKeys.noTransactionsTitle), findsOneWidget); + }); + + testWidgets('transaction table shows error state on failure', ( + tester, + ) async { + final coin = buildTestCoin(type: CoinType.smartChain); + final bloc = _FakeTransactionHistoryBloc( + TransactionHistoryState( + transactions: const [], + loading: false, + error: TextError(error: 'network failed'), + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: bloc, + child: CustomScrollView( + slivers: [ + TransactionTable( + coin: coin, + selectedTransaction: null, + setTransaction: (_) {}, + ), + ], + ), + ), + ), + ); + + expect( + find.textContaining(LocaleKeys.connectionToServersFailing), + findsOneWidget, + ); + }); + + testWidgets('transaction details done button calls onClose', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id); + var didClose = false; + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () => didClose = true, + usdPriceResolver: (_, __) => 0, + ), + ), + ); + + await tester.tap(find.text(LocaleKeys.done).first); + await tester.pump(); + + expect(didClose, isTrue); + await _disposeAnimatedWidgets(tester); + }); + + testWidgets('transaction details view on explorer uses tx hash', ( + tester, + ) async { + final coin = buildTestCoin(); + final tx = buildTestTransaction(assetId: coin.id, txHash: 'abc-hash'); + String? launched; + + await tester.pumpWidget( + wrapWithMaterial( + TransactionDetails( + coin: coin, + transaction: tx, + onClose: () {}, + usdPriceResolver: (_, __) => 0, + onLaunchExplorer: (url) => launched = url, + ), + ), + ); + + await tester.tap(find.text(LocaleKeys.viewOnExplorer).first); + await tester.pump(); + + expect(launched, isNotNull); + expect(launched, contains('abc-hash')); + await _disposeAnimatedWidgets(tester); + }); + }); +} + +void main() { + testTransactionViewsWidgets(); +} diff --git a/test_units/tests/wallet/coin_details/withdraw_form_bloc_test.dart b/test_units/tests/wallet/coin_details/withdraw_form_bloc_test.dart new file mode 100644 index 0000000000..061218ee8a --- /dev/null +++ b/test_units/tests/wallet/coin_details/withdraw_form_bloc_test.dart @@ -0,0 +1,1055 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; +import 'package:komodo_defi_sdk/src/withdrawals/withdrawal_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; + +Map _utxoConfig({ + String coin = 'KMD', + String name = 'Komodo', +}) => { + 'coin': coin, + 'type': 'UTXO', + 'name': name, + 'fname': name, + 'wallet_only': false, + 'mm2': 1, + 'chain_id': 141, + 'decimals': 8, + 'is_testnet': false, + 'required_confirmations': 1, + 'derivation_path': "m/44'/141'/0'", + 'protocol': {'type': 'UTXO'}, +}; + +Map _trxConfig() => { + 'coin': 'TRX', + 'type': 'TRX', + 'name': 'TRON', + 'fname': 'TRON', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'required_confirmations': 1, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRX', + 'protocol_data': {'network': 'Mainnet'}, + }, + 'nodes': >[], +}; + +BalanceInfo _balance(String amount) { + final value = Decimal.parse(amount); + return BalanceInfo(total: value, spendable: value, unspendable: Decimal.zero); +} + +Asset _assetFromConfig(Map config) => + Asset.fromJson(config, knownIds: const {}); + +PubkeyInfo _pubkeyForAsset( + Asset asset, { + String address = 'source-address', + String balance = '5', +}) { + return PubkeyInfo( + address: address, + derivationPath: "m/44'/141'/0'/0/0", + chain: 'external', + balance: _balance(balance), + coinTicker: asset.id.id, + ); +} + +AssetPubkeys _assetPubkeys( + Asset asset, { + String address = 'source-address', + String balance = '5', +}) { + return AssetPubkeys( + assetId: asset.id, + keys: [_pubkeyForAsset(asset, address: address, balance: balance)], + availableAddressesCount: 1, + syncStatus: SyncStatusEnum.success, + ); +} + +WithdrawalPreview _utxoPreview({ + required String assetId, + required String txHash, + required String toAddress, + required int timestamp, +}) { + return WithdrawResult( + txHex: 'signed-$txHash', + txHash: txHash, + from: const ['source-address'], + to: [toAddress], + balanceChanges: BalanceChanges( + netChange: Decimal.fromInt(-1), + receivedByMe: Decimal.zero, + spentByMe: Decimal.one, + totalAmount: Decimal.one, + ), + blockHeight: 1, + timestamp: timestamp, + fee: FeeInfo.utxoFixed(coin: assetId, amount: Decimal.parse('0.0001')), + coin: assetId, + ); +} + +WithdrawalPreview _tronPreview({ + required String txHash, + required String toAddress, + required int timestamp, +}) { + return WithdrawResult( + txHex: 'signed-$txHash', + txHash: txHash, + from: const ['source-address'], + to: [toAddress], + balanceChanges: BalanceChanges( + netChange: Decimal.fromInt(-1), + receivedByMe: Decimal.zero, + spentByMe: Decimal.one, + totalAmount: Decimal.one, + ), + blockHeight: 1, + timestamp: timestamp, + fee: FeeInfo.tron( + coin: 'TRX', + bandwidthUsed: 1, + energyUsed: 1, + bandwidthFee: Decimal.zero, + energyFee: Decimal.parse('0.1'), + totalFeeAmount: Decimal.parse('0.1'), + ), + coin: 'TRX', + ); +} + +WithdrawalFeeOptions _utxoFeeOptions(String assetId) { + WithdrawalFeeOption option(WithdrawalFeeLevel priority, String amount) { + return WithdrawalFeeOption( + priority: priority, + feeInfo: FeeInfo.utxoFixed(coin: assetId, amount: Decimal.parse(amount)), + ); + } + + return WithdrawalFeeOptions( + coin: assetId, + low: option(WithdrawalFeeLevel.low, '0.00001'), + medium: option(WithdrawalFeeLevel.medium, '0.00002'), + high: option(WithdrawalFeeLevel.high, '0.00003'), + ); +} + +WithdrawalResult _resultFromPreview(WithdrawalPreview preview) { + return WithdrawalResult( + txHash: preview.txHash, + balanceChanges: preview.balanceChanges, + coin: preview.coin, + toAddress: preview.to.first, + fee: preview.fee, + ); +} + +Future _flush() => Future.delayed(Duration.zero); + +Future _awaitSourceSelection(WithdrawFormBloc bloc) async { + if (bloc.state.selectedSourceAddress != null) { + return; + } + await bloc.stream.firstWhere((state) => state.selectedSourceAddress != null); +} + +Future _primeFillState( + WithdrawFormBloc bloc, { + required String recipient, + required String amount, +}) async { + await _awaitSourceSelection(bloc); + + final recipientState = bloc.stream.firstWhere( + (state) => state.recipientAddress == recipient, + ); + bloc.add(WithdrawFormRecipientChanged(recipient)); + await recipientState; + + final amountState = bloc.stream.firstWhere((state) => state.amount == amount); + bloc.add(WithdrawFormAmountChanged(amount)); + await amountState; +} + +class _FakeAddressOperations implements AddressOperations { + _FakeAddressOperations({this.validateAddressHandler}); + + final Future Function({ + required Asset asset, + required String address, + })? + validateAddressHandler; + + @override + Future validateAddress({ + required Asset asset, + required String address, + }) { + return validateAddressHandler?.call(asset: asset, address: address) ?? + Future.value( + AddressValidation(isValid: true, address: address, asset: asset), + ); + } + + @override + Future convertFormat({ + required Asset asset, + required String address, + required AddressFormat format, + }) { + return Future.value( + AddressConversionResult( + originalAddress: address, + convertedAddress: address, + asset: asset, + format: format, + ), + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeWithdrawalManager implements WithdrawalManager { + _FakeWithdrawalManager({ + required Future Function(WithdrawParameters params) + previewWithdrawalHandler, + Future Function(String assetId)? + getFeeOptionsHandler, + Stream Function( + WithdrawalPreview preview, + String assetId, + )? + executeWithdrawalHandler, + }) : _previewWithdrawalHandler = previewWithdrawalHandler, + _getFeeOptionsHandler = getFeeOptionsHandler, + _executeWithdrawalHandler = executeWithdrawalHandler; + + final Future Function(WithdrawParameters params) + _previewWithdrawalHandler; + final Future Function(String assetId)? + _getFeeOptionsHandler; + final Stream Function( + WithdrawalPreview preview, + String assetId, + )? + _executeWithdrawalHandler; + + int previewCallCount = 0; + int executeCallCount = 0; + final List previewRequests = []; + + @override + Future previewWithdrawal(WithdrawParameters params) async { + previewCallCount += 1; + previewRequests.add(params); + return _previewWithdrawalHandler(params); + } + + @override + Future getFeeOptions(String assetId) async { + return _getFeeOptionsHandler?.call(assetId); + } + + @override + Stream executeWithdrawal( + WithdrawalPreview preview, + String assetId, + ) { + executeCallCount += 1; + return _executeWithdrawalHandler?.call(preview, assetId) ?? + const Stream.empty(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakePubkeyManager implements PubkeyManager { + _FakePubkeyManager(this._pubkeysByAssetId); + + final Map _pubkeysByAssetId; + + @override + AssetPubkeys? lastKnown(AssetId assetId) => _pubkeysByAssetId[assetId]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeBalanceManager implements BalanceManager { + _FakeBalanceManager(this._balances); + + final Map _balances; + + @override + BalanceInfo? lastKnown(AssetId assetId) => _balances[assetId]; + + @override + Future getBalance(AssetId assetId) async => + _balances[assetId] ?? BalanceInfo.zero(); + + @override + Stream watchBalance( + AssetId assetId, { + bool activateIfNeeded = true, + }) async* { + final balance = _balances[assetId]; + if (balance != null) { + yield balance; + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSdk implements KomodoDefiSdk { + _FakeSdk({ + required this.addresses, + required this.withdrawals, + required this.pubkeys, + required this.balances, + }); + + @override + final AddressOperations addresses; + + @override + final WithdrawalManager withdrawals; + + @override + final PubkeyManager pubkeys; + + @override + final BalanceManager balances; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMm2Api implements Mm2Api { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void testWithdrawFormBloc() { + group('WithdrawFormBloc', () { + test('preview completion uses the original request snapshot', () async { + final asset = _assetFromConfig(_utxoConfig()); + final previewCompleter = Completer(); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + bloc.add(const WithdrawFormAmountChanged('2')); + await _flush(); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final confirmState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + expect(withdrawals.previewCallCount, 1); + expect(withdrawals.previewRequests.single.amount, Decimal.one); + expect(confirmState.amount, '1'); + expect(confirmState.recipientAddress, 'recipient-1'); + }); + + test( + 'preview completion preserves concurrent fee option updates', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final feeOptionsCompleter = Completer(); + final previewCompleter = Completer(); + final expectedFeeOptions = _utxoFeeOptions(asset.id.id); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + getFeeOptionsHandler: (_) => feeOptionsCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + feeOptionsCompleter.complete(expectedFeeOptions); + await _flush(); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final confirmState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + expect(confirmState.feeOptions, expectedFeeOptions); + }, + ); + + test( + 'preview completion survives fee priority defaulting during request', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final feeOptionsCompleter = Completer(); + final previewCompleter = Completer(); + final expectedFeeOptions = _utxoFeeOptions(asset.id.id); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + getFeeOptionsHandler: (_) => feeOptionsCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + feeOptionsCompleter.complete(expectedFeeOptions); + final feeDefaultedState = await bloc.stream.firstWhere( + (state) => + state.isSending && + state.selectedFeePriority == WithdrawalFeeLevel.medium, + ); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final confirmState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + expect(withdrawals.previewRequests.single.feePriority, isNull); + expect(feeDefaultedState.feeOptions, expectedFeeOptions); + expect(confirmState.feeOptions, expectedFeeOptions); + expect(confirmState.selectedFeePriority, WithdrawalFeeLevel.medium); + }, + ); + + test( + 'stale preview results are discarded after request inputs change', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final previewCompleter = Completer(); + final initialPubkey = PubkeyInfo( + address: 'source-address-1', + derivationPath: "m/44'/141'/0'/0/0", + chain: 'external', + balance: _balance('5'), + coinTicker: asset.id.id, + ); + final updatedPubkey = PubkeyInfo( + address: 'source-address-2', + derivationPath: "m/44'/141'/0'/0/1", + chain: 'external', + balance: _balance('5'), + coinTicker: asset.id.id, + ); + final pubkeysByAssetId = { + asset.id: AssetPubkeys( + assetId: asset.id, + keys: [initialPubkey], + availableAddressesCount: 1, + syncStatus: SyncStatusEnum.success, + ), + }; + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager(pubkeysByAssetId), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + + pubkeysByAssetId[asset.id] = AssetPubkeys( + assetId: asset.id, + keys: [updatedPubkey], + availableAddressesCount: 1, + syncStatus: SyncStatusEnum.success, + ); + bloc.add(const WithdrawFormSourcesLoadRequested()); + await bloc.stream.firstWhere( + (state) => + state.selectedSourceAddress?.derivationPath == + updatedPubkey.derivationPath, + ); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + final settledState = await bloc.stream.firstWhere( + (state) => + !state.isSending && + state.selectedSourceAddress?.derivationPath == + updatedPubkey.derivationPath, + ); + + expect( + withdrawals.previewRequests.single.from, + WithdrawalSource.hdDerivationPath(initialPubkey.derivationPath!), + ); + expect(settledState.step, WithdrawFormStep.fill); + expect(settledState.preview, isNull); + expect( + settledState.selectedSourceAddress?.derivationPath, + updatedPubkey.derivationPath, + ); + }, + ); + + test( + 'duplicate preview submissions are dropped while one is running', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final previewCompleter = Completer(); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) => previewCompleter.future, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + final sendingState = bloc.stream.firstWhere((state) => state.isSending); + bloc.add(const WithdrawFormPreviewSubmitted()); + bloc.add(const WithdrawFormPreviewSubmitted()); + await sendingState; + await _flush(); + + expect(withdrawals.previewCallCount, 1); + + previewCompleter.complete( + _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ), + ); + + await bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.confirm, + ); + }, + ); + + test( + 'recipient validation keeps the latest input when async checks overlap', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final validations = >{ + 'recipient-1': Completer(), + 'recipient-2': Completer(), + }; + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations( + validateAddressHandler: + ({required Asset asset, required String address}) => + validations[address]!.future, + ), + withdrawals: _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _utxoPreview( + assetId: asset.id.id, + txHash: 'unused', + toAddress: 'recipient-2', + timestamp: 1, + ), + ), + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _awaitSourceSelection(bloc); + + bloc.add(const WithdrawFormRecipientChanged('recipient-1')); + await bloc.stream.firstWhere( + (state) => state.recipientAddress == 'recipient-1', + ); + + bloc.add(const WithdrawFormRecipientChanged('recipient-2')); + await bloc.stream.firstWhere( + (state) => state.recipientAddress == 'recipient-2', + ); + + validations['recipient-1']!.complete( + AddressValidation( + isValid: false, + address: 'recipient-1', + asset: asset, + invalidReason: 'invalid recipient-1', + ), + ); + await _flush(); + + expect(bloc.state.recipientAddress, 'recipient-2'); + expect(bloc.state.recipientAddressError, isNull); + + validations['recipient-2']!.complete( + AddressValidation( + isValid: true, + address: 'recipient-2', + asset: asset, + ), + ); + await _flush(); + + expect(bloc.state.recipientAddress, 'recipient-2'); + expect(bloc.state.recipientAddressError, isNull); + }, + ); + + test( + 'TRON preview refresh drops duplicate requests and preserves confirm state', + () async { + final asset = _assetFromConfig(_trxConfig()); + final refreshCompleter = Completer(); + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + var previewInvocation = 0; + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async { + previewInvocation += 1; + if (previewInvocation == 1) { + return _tronPreview( + txHash: 'preview-1', + toAddress: 'tron-recipient', + timestamp: now, + ); + } + + return refreshCompleter.future; + }, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'tron-recipient', amount: '1'); + + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + state.preview?.txHash == 'preview-1', + ); + + final refreshingState = bloc.stream.firstWhere( + (state) => state.isPreviewRefreshing, + ); + bloc.add(const WithdrawFormTronPreviewRefreshRequested()); + bloc.add(const WithdrawFormTronPreviewRefreshRequested()); + await refreshingState; + await _flush(); + + expect(withdrawals.previewCallCount, 2); + + refreshCompleter.complete( + _tronPreview( + txHash: 'preview-2', + toAddress: 'tron-recipient', + timestamp: now + 5, + ), + ); + + final refreshedState = await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && + !state.isPreviewRefreshing && + state.preview?.txHash == 'preview-2', + ); + + expect(withdrawals.previewCallCount, 2); + expect(refreshedState.amount, '1'); + expect(refreshedState.recipientAddress, 'tron-recipient'); + expect(refreshedState.previewSecondsRemaining, isNotNull); + }, + ); + + test( + 'duplicate submit events are dropped while broadcast is running', + () async { + final asset = _assetFromConfig(_utxoConfig()); + final preview = _utxoPreview( + assetId: asset.id.id, + txHash: 'preview-1', + toAddress: 'recipient-1', + timestamp: 1, + ); + final progressController = StreamController(); + addTearDown(progressController.close); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => preview, + executeWithdrawalHandler: (_, __) => progressController.stream, + ); + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.confirm, + ); + + final sendingState = bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.confirm && state.isSending, + ); + bloc.add(const WithdrawFormSubmitted()); + bloc.add(const WithdrawFormSubmitted()); + await sendingState; + await _flush(); + + expect(withdrawals.executeCallCount, 1); + + progressController.add( + WithdrawalProgress( + status: WithdrawalStatus.complete, + message: 'done', + withdrawalResult: _resultFromPreview(preview), + ), + ); + + final successState = await bloc.stream.firstWhere( + (state) => state.step == WithdrawFormStep.success, + ); + + expect(withdrawals.executeCallCount, 1); + expect(successState.result?.txHash, 'preview-1'); + }, + ); + + test('submit ignores expired TRON preview until refresh', () async { + final asset = _assetFromConfig(_trxConfig()); + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _tronPreview( + txHash: 'expired-preview', + toAddress: 'tron-recipient', + timestamp: now - 120, + ), + executeWithdrawalHandler: (_, __) async* {}, + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'tron-recipient', amount: '1'); + + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere( + (state) => + state.step == WithdrawFormStep.confirm && state.isPreviewExpired, + ); + + bloc.add(const WithdrawFormSubmitted()); + await _flush(); + + expect(withdrawals.executeCallCount, 0); + expect(bloc.state.step, WithdrawFormStep.confirm); + expect(bloc.state.confirmStepError, isNotNull); + }); + + test('send max recomputes amount when source address changes', () async { + final asset = _assetFromConfig(_utxoConfig()); + final sourceOne = _pubkeyForAsset( + asset, + address: 'source-one', + balance: '5', + ); + final sourceTwo = _pubkeyForAsset( + asset, + address: 'source-two', + balance: '2', + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => _utxoPreview( + assetId: asset.id.id, + txHash: 'unused', + toAddress: 'recipient', + timestamp: 1, + ), + ), + pubkeys: _FakePubkeyManager({ + asset.id: AssetPubkeys( + assetId: asset.id, + keys: [sourceOne, sourceTwo], + availableAddressesCount: 2, + syncStatus: SyncStatusEnum.success, + ), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + bloc.add(WithdrawFormSourceChanged(sourceOne)); + await bloc.stream.firstWhere( + (state) => state.selectedSourceAddress?.address == 'source-one', + ); + + bloc.add(const WithdrawFormMaxAmountEnabled(true)); + await bloc.stream.firstWhere( + (state) => state.isMaxAmount && state.amount == '5', + ); + + bloc.add(WithdrawFormSourceChanged(sourceTwo)); + final updated = await bloc.stream.firstWhere( + (state) => + state.selectedSourceAddress?.address == 'source-two' && + state.amount == '2', + ); + + expect(updated.isMaxAmount, isTrue); + }); + + test('preview refresh recovers after expired quote', () async { + final asset = _assetFromConfig(_trxConfig()); + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + var previewCalls = 0; + final refreshedCompleter = Completer(); + + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async { + previewCalls += 1; + if (previewCalls == 1) { + return _tronPreview( + txHash: 'expired-preview', + toAddress: 'tron-recipient', + timestamp: now - 120, + ); + } + + return refreshedCompleter.future; + }, + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({ + asset.id: _assetPubkeys(asset, balance: '5'), + }), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'tron-recipient', amount: '1'); + bloc.add(const WithdrawFormPreviewSubmitted()); + await bloc.stream.firstWhere((state) => state.isPreviewExpired); + + final refreshing = bloc.stream.firstWhere( + (state) => state.isPreviewRefreshing, + ); + bloc.add(const WithdrawFormTronPreviewRefreshRequested()); + await refreshing; + + refreshedCompleter.complete( + _tronPreview( + txHash: 'fresh-preview', + toAddress: 'tron-recipient', + timestamp: now + 10, + ), + ); + + final refreshed = await bloc.stream.firstWhere( + (state) => + !state.isPreviewRefreshing && + !state.isPreviewExpired && + state.preview?.txHash == 'fresh-preview', + ); + + expect(withdrawals.previewCallCount, 2); + expect(refreshed.confirmStepError, isNull); + }); + + test('validation maps known sdk errors to user-facing state', () async { + final asset = _assetFromConfig(_utxoConfig()); + final withdrawals = _FakeWithdrawalManager( + previewWithdrawalHandler: (_) async => + throw Exception('insufficient gas for transaction'), + ); + + final bloc = WithdrawFormBloc( + asset: asset, + sdk: _FakeSdk( + addresses: _FakeAddressOperations(), + withdrawals: withdrawals, + pubkeys: _FakePubkeyManager({asset.id: _assetPubkeys(asset)}), + balances: _FakeBalanceManager({asset.id: _balance('5')}), + ), + mm2Api: _FakeMm2Api(), + ); + addTearDown(bloc.close); + + await _primeFillState(bloc, recipient: 'recipient-1', amount: '1'); + bloc.add(const WithdrawFormPreviewSubmitted()); + + final errored = await bloc.stream.firstWhere( + (state) => state.previewError != null, + ); + + expect( + errored.previewError!.message, + contains('notEnoughBalanceForGasError'), + ); + }); + }); +} + +void main() { + testWithdrawFormBloc(); +} diff --git a/test_units/tests/wallet/coin_details/withdraw_form_fill_section_test.dart b/test_units/tests/wallet/coin_details/withdraw_form_fill_section_test.dart new file mode 100644 index 0000000000..30f251212d --- /dev/null +++ b/test_units/tests/wallet/coin_details/withdraw_form_fill_section_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/withdraw_form.dart'; + +Map _utxoConfig() => { + 'coin': 'KMD', + 'type': 'UTXO', + 'name': 'Komodo', + 'fname': 'Komodo', + 'wallet_only': false, + 'mm2': 1, + 'chain_id': 141, + 'decimals': 8, + 'is_testnet': false, + 'required_confirmations': 1, + 'derivation_path': "m/44'/141'/0'", + 'protocol': {'type': 'UTXO'}, +}; + +class _FakeWithdrawFormBloc extends Cubit + implements WithdrawFormBloc { + _FakeWithdrawFormBloc(super.initialState); + + final List events = []; + + @override + void add(WithdrawFormEvent event) { + events.add(event); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _buildTestWidget(WithdrawFormBloc bloc) { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(1280, 1200)), + child: Builder( + builder: (context) { + updateScreenType(context); + return BlocProvider.value( + value: bloc, + child: const Scaffold( + body: SingleChildScrollView( + child: WithdrawFormFillSection(suppressPreviewError: false), + ), + ), + ); + }, + ), + ), + ); +} + +void testWithdrawFormFillSection() { + group('WithdrawFormFillSection', () { + testWidgets('locks editable controls while preview is sending', ( + tester, + ) async { + final asset = Asset.fromJson(_utxoConfig(), knownIds: const {}); + final bloc = _FakeWithdrawFormBloc( + WithdrawFormState( + asset: asset, + step: WithdrawFormStep.fill, + recipientAddress: 'recipient', + amount: '1', + isSending: true, + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget(_buildTestWidget(bloc)); + + final lockWidget = tester.widget( + find.byKey(const Key('withdraw-form-fill-input-lock')), + ); + + expect(lockWidget.ignoring, isTrue); + }); + + testWidgets('keeps editable controls enabled when not sending', ( + tester, + ) async { + final asset = Asset.fromJson(_utxoConfig(), knownIds: const {}); + final bloc = _FakeWithdrawFormBloc( + WithdrawFormState( + asset: asset, + step: WithdrawFormStep.fill, + recipientAddress: 'recipient', + amount: '1', + isSending: false, + ), + ); + addTearDown(bloc.close); + + await tester.pumpWidget(_buildTestWidget(bloc)); + + final lockWidget = tester.widget( + find.byKey(const Key('withdraw-form-fill-input-lock')), + ); + + expect(lockWidget.ignoring, isFalse); + }); + }); +} + +void main() { + testWithdrawFormFillSection(); +}