diff --git a/assets/translations/en.json b/assets/translations/en.json index 584d1ac0e1..7b6f664a72 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -369,7 +369,8 @@ "seedIntroWarning": "This phrase is the main access to your\nassets, save and never share this phrase", "seedSettings": "Seed phrase", "errorDescription": "Error description", - "tryAgain": "Oops! Something went wrong. \nPlease try again. \nIf it didn't help - contact us.", + "tryAgain": "Try again", + "errorTryAgainSupportHint": "Something went wrong. Try again, or contact us if the problem continues.", "customFeesWarning": "Only use custom fees if you know what you are doing!", "fiatExchange": "Exchange", "bridgeExchange": "Exchange", @@ -419,6 +420,7 @@ "setMin": "Set Min", "timeout": "Timeout", "notEnoughBalanceForGasError": "Not enough balance to pay gas.", + "cannotSendToSelf": "Cannot send funds to the same address you are sending from. Please use a different recipient address.", "notEnoughFundsError": "Not enough funds to perform a trade", "dexErrorMessage": "Something went wrong!", "dexUnableToStartSwap": "Unable to start swap. Refresh the quote and try again.", @@ -482,6 +484,19 @@ "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", + "withdrawTronBandwidthUsed": "Bandwidth (NET) used", + "withdrawTronBandwidthFee": "Bandwidth fee", + "withdrawTronBandwidthSource": "Bandwidth source", + "withdrawTronEnergyUsed": "Energy used", + "withdrawTronEnergyFee": "Energy fee", + "withdrawTronEnergySource": "Energy source", + "withdrawTronFeeSummary": "TRON fee summary", + "withdrawTronFeePaidIn": "Paid in {}", + "withdrawTronBandwidthCovered": "Covered by free NET bandwidth", + "withdrawTronEnergyCovered": "Covered by free energy", + "withdrawTronResourceNotUsed": "Not used", + "withdrawTronFeeSummaryCharged": "Network will charge {} {}.", + "withdrawTronFeeSummaryCovered": "No {} fee will be charged (covered by resources).", "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/app_config/app_config.dart b/lib/app_config/app_config.dart index f4a192dfe1..415c8379ab 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -36,6 +36,11 @@ const bool isBitrefillIntegrationEnabled = false; ///! trading purposes where it is not legally compliant. const bool kShowTradingWarning = false; +/// Controls whether the HD mode warning banner is shown on the wallet page. +/// TODO: Replace this static flag with conditional visibility once we can +/// determine whether the wallet has previously been used in legacy mode. +const bool kShowHdWalletWarningBanner = false; + const Duration kPerformanceLogInterval = Duration(minutes: 1); /// Enable debug logging for electrum connections and RPC methods. 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 339021156b..495526d577 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 @@ -165,7 +165,6 @@ class PortfolioGrowthBloc filteredEventCoins, delay: kActivationPollingInterval, ); - // Only remove inactivate/activating coins after an attempt to load the // cached chart, as the cached chart may contain inactive coins. await _loadChart( @@ -196,9 +195,9 @@ class PortfolioGrowthBloc PortfolioGrowthLoadRequested event, { required bool useCache, }) async { - final activeCoins = await coins.removeInactiveCoins(_sdk); + final chartCoins = useCache ? coins : await coins.removeInactiveCoins(_sdk); final chart = await _portfolioGrowthRepository.getPortfolioGrowthChart( - activeCoins, + chartCoins, fiatCoinId: event.fiatCoinId, walletId: event.walletId, useCache: useCache, diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart index 8fb93d9f1c..bc3658d190 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart @@ -161,10 +161,26 @@ class PortfolioGrowthRepository { ); if (transactions.isEmpty) { - _log.fine('No transactions found for ${coin.id}, caching empty chart'); - // Insert an empty chart into the cache to avoid fetching transactions - // again for each invocation. The assumption is that this function is - // called later with useCache set to false to fetch the transactions again + _log.fine('No transactions found for ${coin.id}'); + + final String compoundKey = GraphCache.getPrimaryKey( + coinId: coinId.id, + fiatCoinId: fiatCoinId, + graphType: GraphType.balanceGrowth, + walletId: walletId, + isHdWallet: currentUser.isHd, + ); + final existingCache = await _graphCache.get(compoundKey); + if (existingCache != null && existingCache.graph.isNotEmpty) { + _log.fine( + 'Keeping existing non-empty cache for ${coin.id} ' + '(${existingCache.graph.length} points) ' + 'instead of overwriting with empty transactions', + ); + methodStopwatch.stop(); + return existingCache.graph; + } + final cacheInsertStopwatch = Stopwatch()..start(); await _graphCache.insert( GraphCache( 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 34863db3c5..31b8245a97 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 @@ -61,8 +61,6 @@ class ProfitLossBloc extends Bloc { final supportedCoins = await event.coins.filterSupportedCoins(); final filteredEventCoins = event.coins.withoutTestCoins(); final initialActiveCoins = await supportedCoins.removeInactiveCoins(_sdk); - // Charts for individual coins (coin details) are parsed here as well, - // and should be hidden if not supported. if (supportedCoins.isEmpty && filteredEventCoins.length <= 1) { return emit( PortfolioProfitLossChartUnsupported( @@ -78,8 +76,6 @@ class ProfitLossBloc extends Bloc { ).then(emit.call).catchError((Object error, StackTrace stackTrace) { const errorMessage = 'Failed to load CACHED portfolio profit/loss'; _log.warning(errorMessage, error, stackTrace); - // ignore cached errors, as the periodic refresh attempts should recover - // at the cost of a longer first loading time. }); // Fetch the un-cached version of the chart to update the cache. @@ -97,10 +93,6 @@ class ProfitLossBloc extends Bloc { useCache: false, ).then(emit.call).catchError((Object e, StackTrace s) { _log.severe('Failed to load uncached profit/loss chart', e, s); - // Ignore un-cached errors, as a transaction loading exception should not - // make the graph disappear with a load failure emit, as the cached data - // is already displayed. The periodic updates will still try to fetch the - // data and update the graph. }); } } catch (error, stackTrace) { diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart index 5444fe33eb..061e091515 100644 --- a/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart @@ -163,7 +163,24 @@ class ProfitLossRepository { ); if (transactions.isEmpty) { - _log.fine('No transactions found for ${coinId.id}, caching empty result'); + _log.fine('No transactions found for ${coinId.id}'); + + final String compoundKey = ProfitLossCache.getPrimaryKey( + coinId: coinId.id, + fiatCurrency: fiatCoinId, + walletId: walletId, + isHdWallet: currentUser.isHd, + ); + final existingCache = await _profitLossCacheProvider.get(compoundKey); + if (existingCache != null && existingCache.profitLosses.isNotEmpty) { + _log.fine( + 'Keeping existing non-empty cache for ${coinId.id} ' + '(${existingCache.profitLosses.length} entries) ' + 'instead of overwriting with empty transactions', + ); + methodStopwatch.stop(); + return existingCache.profitLosses; + } final cacheInsertStopwatch = Stopwatch()..start(); await _profitLossCacheProvider.insert( diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index af637c1bd8..1eed26da30 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -24,6 +24,7 @@ extension AssetCoinExtension on Asset { platform: id.parentId?.id ?? platform ?? '', contractAddress: contractAddress ?? '', ); + final explorerPattern = protocol.explorerPattern; return Coin( type: protocol.subClass.toCoinType(), @@ -32,10 +33,9 @@ extension AssetCoinExtension on Asset { name: id.name, logoImageUrl: logoImageUrl ?? '', isCustomCoin: isCustomToken, - explorerUrl: config.valueOrNull('explorer_url') ?? '', - explorerTxUrl: config.valueOrNull('explorer_tx_url') ?? '', - explorerAddressUrl: - config.valueOrNull('explorer_address_url') ?? '', + explorerUrl: explorerPattern.baseUrl?.toString() ?? '', + explorerTxUrl: explorerPattern.txPattern ?? '', + explorerAddressUrl: explorerPattern.addressPattern ?? '', protocolType: protocol.subClass.ticker, protocolData: protocolData, isTestCoin: protocol.isTestnet, @@ -113,7 +113,7 @@ extension CoinTypeExtension on CoinSubClass { case CoinSubClass.erc20: return CoinType.erc20; case CoinSubClass.grc20: - return CoinType.erc20; + return CoinType.grc20; case CoinSubClass.krc20: return CoinType.krc20; case CoinSubClass.zhtlc: @@ -209,6 +209,8 @@ extension CoinSubClassExtension on CoinType { return CoinSubClass.smartBch; case CoinType.erc20: return CoinSubClass.erc20; + case CoinType.grc20: + return CoinSubClass.grc20; case CoinType.krc20: return CoinSubClass.krc20; case CoinType.zhtlc: diff --git a/lib/bloc/withdraw_form/withdraw_form_bloc.dart b/lib/bloc/withdraw_form/withdraw_form_bloc.dart index 2ab56ffa73..ba599f24de 100644 --- a/lib/bloc/withdraw_form/withdraw_form_bloc.dart +++ b/lib/bloc/withdraw_form/withdraw_form_bloc.dart @@ -58,6 +58,7 @@ class WithdrawFormBloc extends Bloc { on(_onSubmitted); on(_onCancelled); on(_onReset); + on(_onStepReverted); on(_onSourcesLoadRequested); on(_onFeeOptionsRequested); on(_onConvertAddress); @@ -86,9 +87,37 @@ class WithdrawFormBloc extends Bloc { return fallbackPrefix == null ? message : '$fallbackPrefix: $message'; } + String _extractTechnicalDetails(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(); + } + + TextError _buildTextError(Object error, {String? fallbackPrefix}) { + return TextError( + error: _formatErrorMessage(error, fallbackPrefix: fallbackPrefix), + technicalDetails: _extractTechnicalDetails(error), + ); + } + String _normalizeCommonErrors(String message) { final normalized = message.toLowerCase(); + if (normalized.contains('cannot transfer') && + normalized.contains('to yourself')) { + return LocaleKeys.cannotSendToSelf.tr(); + } + if (normalized.contains('insufficient') && (normalized.contains('gas') || normalized.contains('fee'))) { return LocaleKeys.notEnoughBalanceForGasError.tr(); @@ -603,6 +632,17 @@ class WithdrawFormBloc extends Bloc { return; } + if (_isSelfTransfer) { + emit( + state.copyWith( + previewError: () => + TextError(error: LocaleKeys.cannotSendToSelf.tr()), + isSending: false, + ), + ); + return; + } + try { emit( state.copyWith( @@ -617,9 +657,8 @@ class WithdrawFormBloc extends Bloc { emit(state.copyWith(isAwaitingTrezorConfirmation: true)); } - final preview = await _sdk.withdrawals.previewWithdrawal( - state.toWithdrawParameters(), - ); + final params = state.toWithdrawParameters(); + final preview = await _sdk.withdrawals.previewWithdrawal(params); emit( state.copyWith( @@ -645,12 +684,8 @@ class WithdrawFormBloc extends Bloc { emit( state.copyWith( - previewError: () => TextError( - error: _formatErrorMessage( - e, - fallbackPrefix: 'Failed to generate preview', - ), - ), + previewError: () => + _buildTextError(e, fallbackPrefix: 'Failed to generate preview'), isSending: false, isAwaitingTrezorConfirmation: false, ), @@ -749,9 +784,8 @@ class WithdrawFormBloc extends Bloc { emit( state.copyWith( - transactionError: () => TextError( - error: _formatErrorMessage(e, fallbackPrefix: 'Transaction failed'), - ), + transactionError: () => + _buildTextError(e, fallbackPrefix: 'Transaction failed'), step: WithdrawFormStep.failed, isSending: false, isAwaitingTrezorConfirmation: false, @@ -763,6 +797,13 @@ class WithdrawFormBloc extends Bloc { bool get _isUnsupportedSiaHardwareWalletFlow => _walletType == WalletType.trezor && state.asset.protocol is SiaProtocol; + bool get _isSelfTransfer { + final source = state.selectedSourceAddress?.address; + final recipient = state.recipientAddress.trim(); + if (source == null || recipient.isEmpty) return false; + return source == recipient; + } + void _onCancelled( WithdrawFormCancelled event, Emitter emit, @@ -785,6 +826,40 @@ class WithdrawFormBloc extends Bloc { ); } + void _onStepReverted( + WithdrawFormStepReverted event, + Emitter emit, + ) { + if (state.step == WithdrawFormStep.confirm) { + emit( + state.copyWith( + step: WithdrawFormStep.fill, + preview: () => null, + previewError: () => null, + transactionError: () => null, + isSending: false, + isAwaitingTrezorConfirmation: false, + ), + ); + return; + } + + if (state.step != WithdrawFormStep.failed) return; + + final nextStep = state.preview != null + ? WithdrawFormStep.confirm + : WithdrawFormStep.fill; + + emit( + state.copyWith( + step: nextStep, + transactionError: () => null, + isSending: false, + isAwaitingTrezorConfirmation: false, + ), + ); + } + bool _hasEthAddressMixedCase(String address) { if (!address.startsWith('0x')) return false; final chars = address.substring(2).split(''); diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index af473aa3b6..f0a3ab40db 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -398,6 +398,7 @@ abstract class LocaleKeys { static const seedSettings = 'seedSettings'; static const errorDescription = 'errorDescription'; static const tryAgain = 'tryAgain'; + static const errorTryAgainSupportHint = 'errorTryAgainSupportHint'; static const customFeesWarning = 'customFeesWarning'; static const fiatExchange = 'fiatExchange'; static const bridgeExchange = 'bridgeExchange'; @@ -451,6 +452,7 @@ abstract class LocaleKeys { static const setMin = 'setMin'; static const timeout = 'timeout'; static const notEnoughBalanceForGasError = 'notEnoughBalanceForGasError'; + static const cannotSendToSelf = 'cannotSendToSelf'; static const notEnoughFundsError = 'notEnoughFundsError'; static const dexErrorMessage = 'dexErrorMessage'; static const dexUnableToStartSwap = 'dexUnableToStartSwap'; @@ -519,6 +521,19 @@ abstract class LocaleKeys { static const withdrawPreview = 'withdrawPreview'; static const withdrawPreviewZhtlcNote = 'withdrawPreviewZhtlcNote'; static const withdrawPreviewError = 'withdrawPreviewError'; + 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 withdrawTronFeeSummary = 'withdrawTronFeeSummary'; + static const withdrawTronFeePaidIn = 'withdrawTronFeePaidIn'; + static const withdrawTronBandwidthCovered = 'withdrawTronBandwidthCovered'; + static const withdrawTronEnergyCovered = 'withdrawTronEnergyCovered'; + static const withdrawTronResourceNotUsed = 'withdrawTronResourceNotUsed'; + static const withdrawTronFeeSummaryCharged = 'withdrawTronFeeSummaryCharged'; + static const withdrawTronFeeSummaryCovered = 'withdrawTronFeeSummaryCovered'; static const txHistoryFetchError = 'txHistoryFetchError'; static const txHistoryNoTransactions = 'txHistoryNoTransactions'; static const maxGapLimitReached = 'maxGapLimitReached'; diff --git a/lib/model/coin_type.dart b/lib/model/coin_type.dart index 8cf60087d3..e005ab9332 100644 --- a/lib/model/coin_type.dart +++ b/lib/model/coin_type.dart @@ -25,6 +25,7 @@ enum CoinType { sbch, ubiq, krc20, + grc20, tendermintToken, tendermint, slp, diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart index cee15bc2ba..b2ee5398c1 100644 --- a/lib/model/coin_utils.dart +++ b/lib/model/coin_utils.dart @@ -147,6 +147,8 @@ String getCoinTypeName(CoinType type, [String? symbol]) { return 'TRC-20'; case CoinType.erc20: return 'ERC-20'; + case CoinType.grc20: + return 'GRC-20'; case CoinType.bep20: return 'BEP-20'; case CoinType.qrc20: @@ -203,6 +205,8 @@ bool isParentCoin(CoinType type, String symbol) { return true; case CoinType.erc20: return symbol == 'ETH'; + case CoinType.grc20: + return symbol == 'GLEECT'; case CoinType.bep20: return symbol == 'BNB'; case CoinType.avx20: diff --git a/lib/model/dex_form_error.dart b/lib/model/dex_form_error.dart index f45c088efc..d988722f2c 100644 --- a/lib/model/dex_form_error.dart +++ b/lib/model/dex_form_error.dart @@ -8,6 +8,7 @@ class DexFormError implements TextError { this.type = DexFormErrorType.simple, this.isWarning = false, this.action, + this.technicalDetails, }) : id = const Uuid().v4(); final DexFormErrorType type; @@ -18,6 +19,9 @@ class DexFormError implements TextError { @override final String error; + @override + final String? technicalDetails; + @override String get message => error; } diff --git a/lib/model/text_error.dart b/lib/model/text_error.dart index a75adfedad..b360f430cb 100644 --- a/lib/model/text_error.dart +++ b/lib/model/text_error.dart @@ -1,7 +1,7 @@ import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; class TextError implements BaseError { - TextError({required this.error}); + TextError({required this.error, this.technicalDetails}); static TextError empty() { return TextError(error: ''); } @@ -13,8 +13,14 @@ class TextError implements BaseError { } static const String type = 'TextError'; + + /// User-friendly error message. final String error; + /// Raw technical details for debugging. When non-null, the UI should show + /// this in an expandable "Technical Details" section instead of [error]. + final String? technicalDetails; + @override String get message => error; } diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index d99945667c..3895c6c733 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -190,7 +190,7 @@ Map? rat2fract(Rational? rat, [bool toLog = true]) { String getTxExplorerUrl(Coin coin, String txHash) { final String explorerUrl = coin.explorerUrl; final String explorerTxUrl = coin.explorerTxUrl; - if (explorerUrl.isEmpty) return ''; + if (explorerUrl.isEmpty || explorerTxUrl.isEmpty) return ''; final hash = coin.type == CoinType.tendermint || coin.type == CoinType.tendermintToken @@ -205,7 +205,7 @@ String getTxExplorerUrl(Coin coin, String txHash) { String getAddressExplorerUrl(Coin coin, String address) { final String explorerUrl = coin.explorerUrl; final String explorerAddressUrl = coin.explorerAddressUrl; - if (explorerUrl.isEmpty) return ''; + if (explorerUrl.isEmpty || explorerAddressUrl.isEmpty) return ''; return '$explorerUrl$explorerAddressUrl$address'; } @@ -334,6 +334,7 @@ String abbr2Ticker(String abbr) { const List filteredSuffixes = [ 'TRC20', 'ERC20', + 'GRC20', 'BEP20', 'QRC20', 'FTM20', @@ -401,6 +402,8 @@ Color getProtocolColor(CoinType type) { return const Color(0xFF29F06F); case CoinType.erc20: return const Color.fromRGBO(108, 147, 237, 1); + case CoinType.grc20: + return const Color(0xFF8C41FF); case CoinType.smartChain: return const Color.fromRGBO(32, 22, 49, 1); case CoinType.bep20: @@ -448,13 +451,13 @@ bool hasTxHistorySupport(Coin coin) { return false; case CoinType.trx: case CoinType.trc20: - return true; case CoinType.krc20: case CoinType.tendermint: case CoinType.tendermintToken: case CoinType.utxo: case CoinType.sia: case CoinType.erc20: + case CoinType.grc20: case CoinType.smartChain: case CoinType.bep20: case CoinType.qrc20: @@ -476,11 +479,15 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { final bool hasSupport = hasTxHistorySupport(coin); final coinAddress = address ?? coin.address; assert(!hasSupport); + if (coinAddress == null || coinAddress.isEmpty || coin.explorerUrl.isEmpty) { + return ''; + } + + if (coin.explorerAddressUrl.isNotEmpty) { + return getAddressExplorerUrl(coin, coinAddress); + } switch (coin.type) { - case CoinType.trx: - case CoinType.trc20: - return '${coin.explorerUrl}address/$coinAddress'; case CoinType.sbch: case CoinType.tendermint: return '${coin.explorerUrl}address/$coinAddress'; @@ -493,6 +500,7 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { case CoinType.utxo: case CoinType.smartChain: case CoinType.erc20: + case CoinType.grc20: case CoinType.bep20: case CoinType.qrc20: case CoinType.ftm20: @@ -508,6 +516,8 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { case CoinType.krc20: case CoinType.slp: case CoinType.sia: + case CoinType.trx: + case CoinType.trc20: return '${coin.explorerUrl}address/$coinAddress'; } } diff --git a/lib/shared/widgets/launch_native_explorer_button.dart b/lib/shared/widgets/launch_native_explorer_button.dart index fadc3247ed..32fc628e27 100644 --- a/lib/shared/widgets/launch_native_explorer_button.dart +++ b/lib/shared/widgets/launch_native_explorer_button.dart @@ -6,22 +6,19 @@ import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/shared/utils/utils.dart'; class LaunchNativeExplorerButton extends StatelessWidget { - const LaunchNativeExplorerButton({ - Key? key, - required this.coin, - this.address, - }) : super(key: key); + const LaunchNativeExplorerButton({Key? key, required this.coin, this.address}) + : super(key: key); final Coin coin; final String? address; @override Widget build(BuildContext context) { + final url = getNativeExplorerUrlByCoin(coin, address); + return UiPrimaryButton( width: 160, height: 30, - onPressed: () { - launchURLString(getNativeExplorerUrlByCoin(coin, address)); - }, + onPressed: url.isEmpty ? null : () => launchURLString(url), text: LocaleKeys.viewOnExplorer.tr(), ); } diff --git a/lib/views/wallet/coin_details/coin_details.dart b/lib/views/wallet/coin_details/coin_details.dart index 2def3288d4..8e18ca4897 100644 --- a/lib/views/wallet/coin_details/coin_details.dart +++ b/lib/views/wallet/coin_details/coin_details.dart @@ -9,6 +9,7 @@ import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; import 'package:web_dex/analytics/events/portfolio_events.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info.dart'; import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; import 'package:web_dex/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart'; @@ -61,9 +62,13 @@ class _CoinDetailsState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (ctx) => - TransactionHistoryBloc(sdk: ctx.read()) - ..add(TransactionHistorySubscribe(coin: widget.coin)), + create: (ctx) { + final bloc = TransactionHistoryBloc(sdk: ctx.read()); + if (hasTxHistorySupport(widget.coin)) { + bloc.add(TransactionHistorySubscribe(coin: widget.coin)); + } + return bloc; + }, child: BlocBuilder( builder: (context, state) { return GestureDetector( diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart b/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart index 8b07a86386..def04735b8 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_addresses.dart @@ -247,59 +247,30 @@ class AddressCard extends StatelessWidget { ? _MobileAddressContent( address: address, coin: coin, - onShowFullAddress: () => _showFullAddressDialog(context), + onTapAddress: () => + showPubkeyReceiveDialog(context, coin, address), ) : _DesktopAddressContent( address: address, coin: coin, - onShowFullAddress: () => _showFullAddressDialog(context), + onTapAddress: () => + showPubkeyReceiveDialog(context, coin, address), ), ), ); } +} - void _showFullAddressDialog(BuildContext context) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(LocaleKeys.address.tr()), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SelectableText( - address.address, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - const SizedBox(width: 8), - IconButton( - tooltip: LocaleKeys.copyAddressToClipboard.tr( - args: [coin.abbr], - ), - icon: const Icon(Icons.copy_rounded), - onPressed: () => copyToClipBoard( - context, - address.address, - LocaleKeys.copiedAddressToClipboard.tr(args: [coin.abbr]), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(LocaleKeys.close.tr()), - ), - ], - ); - }, - ); - } +/// Same receive/QR dialog as [QrButton] and the Receive flow. +void showPubkeyReceiveDialog( + BuildContext context, + Coin coin, + PubkeyInfo address, +) { + showDialog( + context: context, + builder: (context) => PubkeyReceiveDialog(coin: coin, address: address), + ); } class _Balance extends StatelessWidget { @@ -320,7 +291,7 @@ class _Balance extends StatelessWidget { return Text( hideBalances - ? '${maskedBalanceText} ${abbr2Ticker(coin.abbr)} ($fiat)' + ? '$maskedBalanceText ${abbr2Ticker(coin.abbr)} ($fiat)' : '${doubleToString(balance)} ${abbr2Ticker(coin.abbr)} ($fiat)', style: TextStyle(fontSize: isMobile ? 12 : 14), ); @@ -331,12 +302,12 @@ class _MobileAddressContent extends StatelessWidget { const _MobileAddressContent({ required this.address, required this.coin, - required this.onShowFullAddress, + required this.onTapAddress, }); final PubkeyInfo address; final Coin coin; - final VoidCallback onShowFullAddress; + final VoidCallback onTapAddress; @override Widget build(BuildContext context) { @@ -350,7 +321,7 @@ class _MobileAddressContent extends StatelessWidget { const SizedBox(width: 8), Expanded( child: InkWell( - onTap: onShowFullAddress, + onTap: onTapAddress, child: TruncatedMiddleText( address.address, style: @@ -388,12 +359,12 @@ class _DesktopAddressContent extends StatelessWidget { const _DesktopAddressContent({ required this.address, required this.coin, - required this.onShowFullAddress, + required this.onTapAddress, }); final PubkeyInfo address; final Coin coin; - final VoidCallback onShowFullAddress; + final VoidCallback onTapAddress; @override Widget build(BuildContext context) { @@ -404,7 +375,7 @@ class _DesktopAddressContent extends StatelessWidget { const SizedBox(width: 12), Expanded( child: InkWell( - onTap: onShowFullAddress, + onTap: onTapAddress, child: TruncatedMiddleText( address.address, style: @@ -470,139 +441,7 @@ class QrButton extends StatelessWidget { splashRadius: 18, icon: const Icon(Icons.qr_code, size: 16), color: Theme.of(context).textTheme.bodyMedium!.color, - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - LocaleKeys.receive.tr(), - style: const TextStyle(fontSize: 16), - ), - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.hardEdge, - child: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ), - ], - ), - content: SizedBox( - width: 450, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - LocaleKeys.onlySendToThisAddress.tr( - args: [abbr2Ticker(coin.abbr)], - ), - style: const TextStyle(fontSize: 14), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - LocaleKeys.network.tr(), - style: const TextStyle(fontSize: 14), - ), - CoinTypeTag(coin), - ], - ), - ), - QrCode(address: address.address, coinAbbr: coin.abbr), - const SizedBox(height: 16), - // Address row with copy and explorer link - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - // Address text - Expanded( - child: TruncatedMiddleText( - address.address, - style: - Theme.of(context).textTheme.bodySmall ?? - const TextStyle(fontSize: 12), - ), - ), - // Copy button - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.hardEdge, - child: IconButton( - tooltip: LocaleKeys.copyAddressToClipboard.tr( - args: [coin.abbr], - ), - icon: const Icon(Icons.copy_rounded, size: 20), - onPressed: () => copyToClipBoard( - context, - address.address, - LocaleKeys.copiedAddressToClipboard.tr( - args: [coin.abbr], - ), - ), - ), - ), - // Explorer link button - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.hardEdge, - child: IconButton( - tooltip: LocaleKeys.viewOnExplorer.tr(), - icon: const Icon(Icons.open_in_new, size: 20), - onPressed: () { - final url = getAddressExplorerUrl( - coin, - address.address, - ); - if (url.isNotEmpty) { - launchURLString(url, inSeparateTab: true); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.explorerUnavailable.tr(), - ), - ), - ); - } - }, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Text( - LocaleKeys.scanTheQrCode.tr(), - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - ], - ), - ), - ), - ); - }, + onPressed: () => showPubkeyReceiveDialog(context, coin, address), ), ); } @@ -896,7 +735,10 @@ class QrCode extends StatelessWidget { child: QrImageView( data: address, backgroundColor: Theme.of(context).textTheme.bodyMedium!.color!, - foregroundColor: theme.custom.dexPageTheme.emptyPlace, + eyeStyle: QrEyeStyle(color: theme.custom.dexPageTheme.emptyPlace), + dataModuleStyle: QrDataModuleStyle( + color: theme.custom.dexPageTheme.emptyPlace, + ), version: QrVersions.auto, size: 200.0, errorCorrectionLevel: QrErrorCorrectLevel.H, diff --git a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart index 1df71d84c1..48a6490f93 100644 --- a/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart @@ -256,11 +256,7 @@ class CoinDetailsReceiveButton extends StatelessWidget { ); if (selectedAddress != null && context.mounted) { - showDialog( - context: context, - builder: (context) => - PubkeyReceiveDialog(coin: coin, address: selectedAddress), - ); + showPubkeyReceiveDialog(context, coin, selectedAddress); } } @@ -292,68 +288,6 @@ class CoinDetailsReceiveButton extends StatelessWidget { } } -class AddressListItem extends StatelessWidget { - const AddressListItem({super.key, required this.address, required this.coin}); - - final PubkeyInfo address; - final Coin coin; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - ), - ), - child: Center( - child: Icon( - Icons.account_balance_wallet_outlined, - size: 18, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - address.formatted, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 2), - Text( - '${address.balance.spendable} ${coin.displayName} available', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.7), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - class CoinDetailsSendButton extends StatelessWidget { const CoinDetailsSendButton({ required this.isMobile, diff --git a/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart b/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart index d3f022ab24..1fbb7f1743 100644 --- a/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart +++ b/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart @@ -15,18 +15,15 @@ class ContractAddressButton extends StatelessWidget { @override Widget build(BuildContext context) { + final contractAddress = coin.protocolData?.contractAddress ?? ''; + final url = getAddressExplorerUrl(coin, contractAddress); + return Material( color: Theme.of(context).textTheme.bodyMedium?.color?.withAlpha(5), borderRadius: BorderRadius.circular(7), child: InkWell( borderRadius: BorderRadius.circular(7), - onTap: coin.explorerUrl.isEmpty - ? null - : () { - launchURLString( - '${coin.explorerUrl}address/${coin.protocolData?.contractAddress ?? ''}', - ); - }, + onTap: url.isEmpty ? null : () => launchURLString(url), child: isMobile ? _ContractAddressMobile(coin) : _ContractAddressDesktop(coin), @@ -97,11 +94,7 @@ class _ContractAddressDesktop extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.only( - left: 13.0, - right: 13.0, - bottom: 5, - ), + padding: const EdgeInsets.only(left: 13.0, right: 13.0, bottom: 5), child: _ContractAddressValue(coin), ), ], @@ -119,19 +112,14 @@ class _ContractAddressValue extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.max, children: [ - AssetIcon.ofTicker( - coin.protocolData?.platform ?? '', - size: 12, - ), - const SizedBox( - width: 3, - ), + AssetIcon.ofTicker(coin.protocolData?.platform ?? '', size: 12), + const SizedBox(width: 3), Text( '${coin.protocolData?.platform ?? ''} ', - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(fontWeight: FontWeight.w500, fontSize: 11), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 11, + ), ), Flexible( child: TruncatedMiddleText( @@ -177,14 +165,12 @@ class _ContractAddressTitle extends StatelessWidget { return Text( LocaleKeys.contractAddress.tr(), style: Theme.of(context).textTheme.titleSmall!.copyWith( - fontSize: 9, - fontWeight: FontWeight.w500, - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: .45), - ), + fontSize: 9, + fontWeight: FontWeight.w500, + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: .45), + ), ); } } diff --git a/lib/views/wallet/coin_details/transactions/transaction_details.dart b/lib/views/wallet/coin_details/transactions/transaction_details.dart index 85c222fd25..8265cc54e6 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_details.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_details.dart @@ -12,15 +12,14 @@ import 'package:web_dex/model/coin.dart'; import 'package:web_dex/shared/utils/formatters.dart'; import 'package:web_dex/shared/utils/utils.dart'; import 'package:web_dex/shared/widgets/copied_text.dart'; -import 'package:web_dex/views/wallet/common/address_copy_button.dart'; class TransactionDetails extends StatelessWidget { const TransactionDetails({ - Key? key, required this.transaction, required this.onClose, required this.coin, - }) : super(key: key); + super.key, + }); final Transaction transaction; final void Function() onClose; @@ -160,69 +159,6 @@ class TransactionDetails extends StatelessWidget { return LocaleKeys.unknown.tr(); } - Widget _buildAddress( - BuildContext context, { - required String title, - required String address, - }) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Title with fixed flex - Expanded( - flex: 2, - child: Text( - title, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontSize: 14), - ), - ), - // Address and copy button - Expanded( - flex: 5, - child: Row( - children: [ - Expanded( - child: AutoScrollText( - text: address, - style: const TextStyle(fontSize: 14), - ), - ), - const SizedBox(width: 8), - AddressCopyButton(address: address), - ], - ), - ), - ], - ), - ); - } - - Widget _buildAddresses(bool isMobile, BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.only(bottom: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAddress( - context, - title: LocaleKeys.from.tr(), - address: transaction.from.first, - ), - _buildAddress( - context, - title: LocaleKeys.to.tr(), - address: transaction.to.first, - ), - ], - ), - ); - } - Widget _buildBalanceChanges(BuildContext context) { final String formatted = formatDexAmt(transaction.amount.toDouble().abs()); final String sign = transaction.amount.toDouble() > 0 ? '+' : '-'; @@ -283,17 +219,32 @@ class TransactionDetails extends StatelessWidget { Widget _buildFee(BuildContext context) { final coinsRepository = RepositoryProvider.of(context); + final String title = LocaleKeys.fees.tr(); - final String formattedFee = transaction.fee?.formatTotal() ?? ''; - final double? usd = coinsRepository.getUsdPriceByAmount( - formattedFee, - _feeCoin, - ); - final String formattedUsd = formatAmt(usd ?? 0); + final String value; + final TextStyle? valueStyle; - final String title = LocaleKeys.fees.tr(); - final String value = - '- ${Coin.normalizeAbbr(_feeCoin)} $formattedFee (\$$formattedUsd)'; + if (transaction.fee == null) { + value = '\u2014'; + valueStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ); + } else { + final String formattedFee = transaction.fee!.formatTotal(); + final double? usd = coinsRepository.getUsdPriceByAmount( + formattedFee, + _feeCoin, + ); + final String formattedUsd = formatAmt(usd ?? 0); + value = + '- ${Coin.normalizeAbbr(_feeCoin)} $formattedFee (\$$formattedUsd)'; + valueStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.decreaseColor, + ); + } return Padding( padding: const EdgeInsets.only(bottom: 15.0), @@ -315,14 +266,7 @@ class TransactionDetails extends StatelessWidget { child: Container( constraints: const BoxConstraints(maxHeight: 35), alignment: Alignment.centerLeft, - child: SelectableText( - value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14, - fontWeight: FontWeight.w500, - color: theme.custom.decreaseColor, - ), - ), + child: SelectableText(value, style: valueStyle), ), ), ], diff --git a/lib/views/wallet/coin_details/transactions/transaction_table.dart b/lib/views/wallet/coin_details/transactions/transaction_table.dart index 9be7d35a0c..204d92d188 100644 --- a/lib/views/wallet/coin_details/transactions/transaction_table.dart +++ b/lib/views/wallet/coin_details/transactions/transaction_table.dart @@ -2,14 +2,15 @@ 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' show showAddressSearch; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/coin_addresses/bloc/coin_addresses_bloc.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:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/shared/utils/utils.dart'; -import 'package:web_dex/shared/widgets/launch_native_explorer_button.dart'; import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; import 'package:web_dex/views/wallet/coin_details/transactions/transaction_list.dart'; @@ -165,14 +166,56 @@ class _IguanaCoinWithoutTxHistorySupport extends StatelessWidget { : super(key: key); final Coin coin; + Future _openExplorer(BuildContext context) async { + final addressesBloc = context.read(); + final addresses = addressesBloc.state.addresses; + if (addresses.isEmpty) { + return; + } + + final PubkeyInfo? selected = addresses.length > 1 + ? await showAddressSearch( + context, + addresses: addresses, + assetNameLabel: coin.abbr, + ) + : addresses.first; + + if (selected == null || !context.mounted) { + return; + } + + final url = getNativeExplorerUrlByCoin(coin, selected.address); + if (url.isEmpty) { + return; + } + launchURLString(url); + } + @override Widget build(BuildContext context) { + final explorerEnabled = context.select((bloc) { + final addresses = bloc.state.addresses; + if (addresses.isEmpty) { + return false; + } + return getNativeExplorerUrlByCoin( + coin, + addresses.first.address, + ).isNotEmpty; + }); + return Column( children: [ Text(LocaleKeys.noTxSupportHidden.tr(), textAlign: TextAlign.center), Padding( padding: const EdgeInsets.only(top: 10.0), - child: LaunchNativeExplorerButton(coin: coin), + child: UiPrimaryButton( + width: 160, + height: 30, + onPressed: explorerEnabled ? () => _openExplorer(context) : null, + text: LocaleKeys.viewOnExplorer.tr(), + ), ), ], ); diff --git a/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart b/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart index 4f47e28438..8dda0ddd48 100644 --- a/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart +++ b/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart @@ -43,11 +43,12 @@ class _SendErrorText extends StatelessWidget { @override Widget build(BuildContext context) { return Text( - LocaleKeys.tryAgain.tr(), + LocaleKeys.errorTryAgainSupportHint.tr(), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: 14, - color: Theme.of(context).colorScheme.error, - ), + fontSize: 14, + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, ); } } @@ -79,11 +80,9 @@ class _SendErrorBody extends StatelessWidget { // TODO: Confirm this is the correct error selector: (state) => state.transactionError, builder: (BuildContext context, error) { - final iconColor = Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: .7); + final iconColor = Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: .7); return Material( color: theme.custom.buttonColorDefault, @@ -103,11 +102,7 @@ class _SendErrorBody extends StatelessWidget { children: [ Expanded(child: _MultilineText(error?.error ?? '')), const SizedBox(width: 16), - Icon( - Icons.copy_rounded, - color: iconColor, - size: 22, - ), + Icon(Icons.copy_rounded, color: iconColor, size: 22), ], ), ), diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart index 64d9ccd3a5..1bbfa2683f 100644 --- a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fields.dart @@ -482,12 +482,23 @@ class FailurePage extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, ), ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + LocaleKeys.errorTryAgainSupportHint.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), const SizedBox(height: 24), OutlinedButton( onPressed: () => context.read().add( const WithdrawFormCancelled(), ), - child: Text(LocaleKeys.tryAgain.tr()), + child: Text(LocaleKeys.tryAgainButton.tr()), ), ], ); 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 8112b8d208..9aaf44c027 100644 --- a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -9,7 +9,9 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/analytics/events/transaction_events.dart'; +import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/common/screen.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; @@ -29,7 +31,6 @@ import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_for 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:decimal/decimal.dart'; import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; bool _isMemoSupportedProtocol(Asset asset) { @@ -115,10 +116,11 @@ class _WithdrawFormState extends State { if (spendable != null && entered != null && amountsMatchWithTolerance(entered, spendable)) { - if (mounted) + if (mounted) { setState(() { _suppressPreviewError = true; }); + } final bloc = context.read(); final agreed = await showDialog( context: context, @@ -140,10 +142,11 @@ class _WithdrawFormState extends State { ), ); - if (mounted) + if (mounted) { setState(() { _suppressPreviewError = false; }); + } if (agreed == true) { bloc.add(const WithdrawFormMaxAmountEnabled(true)); @@ -178,6 +181,7 @@ class _WithdrawFormState extends State { _transactionRefreshTimer?.cancel(); _transactionRefreshTimer = Timer(const Duration(seconds: 2), () { if (!mounted) return; + if (!hasTxHistorySupport(coin)) return; context.read().add( TransactionHistorySubscribe(coin: coin), ); @@ -496,6 +500,14 @@ class WithdrawPreviewDetails extends StatelessWidget { isAutoScrollEnabled: true, ), ], + if (preview.fee is FeeInfoTron) ...[ + const SizedBox(height: 16), + _buildTronFeeDetails( + context, + preview.fee as FeeInfoTron, + useRowLayout: useRowLayout, + ), + ], const SizedBox(height: 16), if (useRowLayout) _buildRow( @@ -558,6 +570,120 @@ class WithdrawPreviewDetails extends StatelessWidget { ); } + Widget _buildTronFeeDetails( + BuildContext context, + FeeInfoTron fee, { + required bool useRowLayout, + }) { + final labelStyle = Theme.of(context).textTheme.labelLarge; + final secondaryValueStyle = Theme.of(context).textTheme.bodySmall; + 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 + : LocaleKeys.withdrawTronBandwidthCovered.tr(); + final energySource = fee.energyUsed == 0 + ? LocaleKeys.withdrawTronResourceNotUsed.tr() + : 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 + ? 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, + ), + ), + 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, + ), + ), + const SizedBox(height: 8), + _buildRow( + feeSummaryLabel, + Text( + chargeSummary, + textAlign: TextAlign.right, + style: secondaryValueStyle, + ), + ), + ], + ); + } + + 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, @@ -741,8 +867,8 @@ class WithdrawFormFillSection extends StatelessWidget { // error state value. if (state.hasPreviewError && !suppressPreviewError) ErrorDisplay( - message: LocaleKeys.withdrawPreviewError.tr(), - detailedMessage: state.previewError!.message, + message: state.previewError!.message, + detailedMessage: state.previewError!.technicalDetails, ), const SizedBox(height: 16), PreviewWithdrawButton( @@ -950,19 +1076,52 @@ class WithdrawResultCard extends StatelessWidget { class WithdrawFormFailedSection extends StatelessWidget { const WithdrawFormFailedSection({super.key}); + static Future _openSupportContact() async { + try { + await openUrl(discordInviteUrl); + } catch (_) { + // Avoid surfacing launch failures as another error state. + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); return BlocBuilder( builder: (context, state) { + final supportLink = TextButton( + onPressed: _openSupportContact, + child: Text(LocaleKeys.support.tr()), + ); + + final backButton = OutlinedButton( + onPressed: () => context.read().add( + const WithdrawFormStepReverted(), + ), + child: Text(LocaleKeys.back.tr()), + ); + + final tryAgainButton = FilledButton( + onPressed: () => + context.read().add(const WithdrawFormReset()), + child: Text(LocaleKeys.tryAgainButton.tr()), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error), + Center( + child: Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + ), const SizedBox(height: 24), Text( LocaleKeys.transactionFailed.tr(), - style: theme.textTheme.headlineMedium?.copyWith( + style: theme.textTheme.headlineSmall?.copyWith( color: theme.colorScheme.error, ), textAlign: TextAlign.center, @@ -970,25 +1129,36 @@ class WithdrawFormFailedSection extends StatelessWidget { const SizedBox(height: 24), if (state.transactionError != null) WithdrawErrorCard(error: state.transactionError!), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - onPressed: () => context.read().add( - const WithdrawFormStepReverted(), - ), - child: Text(LocaleKeys.back.tr()), - ), - const SizedBox(width: 16), - FilledButton( - onPressed: () => context.read().add( - const WithdrawFormReset(), - ), - child: Text(LocaleKeys.tryAgain.tr()), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + LocaleKeys.errorTryAgainSupportHint.tr(), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - ], + textAlign: TextAlign.center, + ), ), + const SizedBox(height: 24), + if (isMobile) ...[ + backButton, + const SizedBox(height: 12), + tryAgainButton, + const SizedBox(height: 8), + Center(child: supportLink), + ] else ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: backButton), + const SizedBox(width: 16), + Expanded(child: tryAgainButton), + ], + ), + const SizedBox(height: 12), + Center(child: supportLink), + ], ], ); }, @@ -1005,6 +1175,12 @@ class WithdrawErrorCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); + final rawDetails = error is TextError + ? (error as TextError).technicalDetails + : null; + final hasDistinctDetails = + rawDetails != null && rawDetails != error.message; + return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -1017,17 +1193,20 @@ class WithdrawErrorCard extends StatelessWidget { ), const SizedBox(height: 8), SelectableText(error.message, style: theme.textTheme.bodyMedium), - if (error is TextError) ...[ + if (hasDistinctDetails) ...[ const SizedBox(height: 16), const Divider(), const SizedBox(height: 16), ExpansionTile( title: Text(LocaleKeys.technicalDetails.tr()), children: [ - SelectableText( - (error as TextError).error, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'Mono', + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + rawDetails, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'Mono', + ), ), ), ], diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index 64d2880015..f0b6e6a974 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -137,7 +137,9 @@ class _WalletMainState extends State with TickerProviderStateMixin { final isLoggedIn = authStateMode == AuthorizeMode.logIn; final walletType = authState.currentUser?.wallet.config.type; final showMultiAddressNotice = - isLoggedIn && walletType == WalletType.hdwallet; + kShowHdWalletWarningBanner && + isLoggedIn && + walletType == WalletType.hdwallet; return ZhtlcConfigurationHandler( child: PageLayout( diff --git a/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart b/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart index e039d19cae..4fc64e7eed 100644 --- a/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart +++ b/lib/views/wallets_manager/widgets/hdwallet_mode_switch.dart @@ -7,10 +7,10 @@ class HDWalletModeSwitch extends StatelessWidget { final ValueChanged onChanged; const HDWalletModeSwitch({ - Key? key, required this.value, required this.onChanged, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/sdk b/sdk index ead35b07d5..39dd1fc605 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit ead35b07d541467efc9867609f2ffb65cf5c4bd8 +Subproject commit 39dd1fc605ddfa009ab1c15ebb631a3be6bde36d