From 5bb779d115cd3c85333c435a15f0cca937efa8c3 Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:34:03 +0100 Subject: [PATCH] CW-1277: Expand PayAnything flow to cover Solana and Tron networks (#2805) * feat: Expand PayAnything flow to cover Solana and Tron networks * fix: refactor utility method to prevent duplicates --- lib/src/screens/send/widgets/send_card.dart | 31 +- .../evm_payment_flow_bottom_sheet.dart | 331 ------------- .../payment_confirmation_bottom_sheet.dart | 22 +- .../token_selection_bottom_sheet.dart | 456 ++++++++++++++++++ lib/utils/token_utilities.dart | 202 ++++---- .../payment/payment_view_model.dart | 116 ++--- 6 files changed, 673 insertions(+), 485 deletions(-) delete mode 100644 lib/src/widgets/bottom_sheet/evm_payment_flow_bottom_sheet.dart create mode 100644 lib/src/widgets/bottom_sheet/token_selection_bottom_sheet.dart diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index a27adea41..5c39e6975 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -7,7 +7,7 @@ import 'package:cake_wallet/src/screens/receive/widgets/currency_input_field.dar import 'package:cake_wallet/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/wallet_switcher_bottom_sheet.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart'; -import 'package:cake_wallet/src/widgets/bottom_sheet/evm_payment_flow_bottom_sheet.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/token_selection_bottom_sheet.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/picker.dart'; import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; @@ -174,10 +174,27 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin _showEVMPaymentFlow( + Future _showTokenSelectionFlow( PaymentViewModel paymentViewModel, WalletSwitcherViewModel walletSwitcherViewModel, - PaymentRequest paymentRequest, - ) async { + PaymentRequest paymentRequest, { + WalletType? fixedNetwork, + }) async { if (!context.mounted) { return; } @@ -252,9 +270,10 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin createState() => _EVMPaymentFlowContentState(); -} - -class _EVMPaymentFlowContentState extends State<_EVMPaymentFlowContent> { - int? selectedChainId; - CryptoCurrency? selectedToken; - - @override - void initState() { - super.initState(); - selectedChainId = widget.paymentViewModel.detectedChainId ?? 1; - _autoSelectToken(); - } - - Future _autoSelectToken() async { - if (selectedChainId == null) return; - - try { - final tokens = await TokenUtilities.getAvailableTokensForChainId(selectedChainId!); - if (tokens.isNotEmpty) { - setState(() { - selectedToken = tokens.first; - }); - } - } catch (e) { - printV('Auto-select token error: $e'); - } - } - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CakeImageWidget( - imageUrl: 'assets/images/eth_chain_mono.svg', - width: 50, - height: 50, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - '${S.current.ethereum_ecosystem}\n${S.current.address_detected.toLowerCase()}', - style: Theme.of(context).textTheme.titleMedium!.copyWith( - fontSize: 20, - fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 36), - Text( - S.current.evm_ecosystem_description, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.0, - ), - ), - const SizedBox(height: 32), - EVMTileWidget( - value: selectedChainId != null - ? _getChainName(selectedChainId!) - : S.current.select_network, - imagePath: selectedChainId != null ? _getChainImagePath(selectedChainId!) : null, - color: selectedChainId != null ? Theme.of(context).colorScheme.primary : null, - enabled: true, - onTap: () => _showNetworkSelection(context), - ), - const SizedBox(height: 16), - EVMTileWidget( - imagePath: selectedToken != null ? selectedToken!.iconPath : null, - value: selectedToken != null ? selectedToken!.title : S.current.select_token, - enabled: selectedChainId != null, - onTap: () => _showTokenSelection(context), - color: selectedToken == null ? Theme.of(context).colorScheme.primary : null, - ), - const SizedBox(height: 32), - PrimaryButton( - text: S.current.restore_next, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - onPressed: selectedChainId != null && selectedToken != null - ? () async => await _handleNext(context) - : null, - ), - const SizedBox(height: 48), - ], - ), - ); - } - - void _showNetworkSelection(BuildContext context) async { - final allChains = evm!.getAllChains(); - final chainIds = allChains.map((chainInfo) => chainInfo.chainId).toList(); - - final selectedIndex = selectedChainId != null ? chainIds.indexOf(selectedChainId!) : 0; - - await showPopUp( - context: context, - builder: (BuildContext context) { - return Picker( - items: chainIds, - displayItem: (int chainId) => _getChainName(chainId), - selectedAtIndex: selectedIndex >= 0 ? selectedIndex : 0, - title: S.current.select_network, - closeOnItemSelected: true, - hasTitleSpacing: true, - images: chainIds.map((chainId) { - final imagePath = _getChainImagePath(chainId); - return imagePath != null - ? CakeImageWidget( - imageUrl: imagePath, - width: 20, - height: 20, - color: Theme.of(context).colorScheme.primary) - : const SizedBox(width: 20, height: 20); - }).toList(), - onItemSelected: (int chainId) { - setState(() { - selectedChainId = chainId; - selectedToken = null; - }); - _autoSelectToken(); - }, - ); - }, - ); - } - - String _getChainName(int chainId) { - final allChains = evm!.getAllChains(); - final chainInfo = allChains.firstWhere( - (chain) => chain.chainId == chainId, - orElse: () => ChainInfo(chainId: chainId, name: 'Unknown Network', shortCode: 'unknown'), - ); - return chainInfo.name; - } - - String? _getChainImagePath(int chainId) { - return getChainMonoImage(WalletType.ethereum, selectedChainId: chainId); - } - - void _showTokenSelection(BuildContext context) async { - if (selectedChainId == null) return; - - try { - final availableTokens = await TokenUtilities.getAvailableTokensForChainId(selectedChainId!); - - if (availableTokens.isEmpty) return; - - final selectedIndex = selectedToken != null ? availableTokens.indexOf(selectedToken!) : 0; - - await showPopUp( - context: context, - builder: (BuildContext context) { - return CurrencyPicker( - selectedAtIndex: selectedIndex >= 0 ? selectedIndex : 0, - items: availableTokens.cast(), - hintText: S.current.add_token, - onItemSelected: (Currency currency) { - setState(() { - selectedToken = currency as CryptoCurrency; - }); - }, - ); - }, - ); - } catch (e) { - printV('Error showing token selection: $e'); - } - } - - Future _handleNext(BuildContext context) async { - if (selectedChainId == null || selectedToken == null) return; - - Navigator.of(context).pop(); - - final allEVMWallets = await widget.paymentViewModel.getEVMCompatibleWallets(); - - final walletType = evm!.getWalletTypeByChainId(selectedChainId!) ?? WalletType.ethereum; - - final detectionResult = AddressDetectionResult( - address: widget.paymentRequest.address, - detectedWalletType: walletType, - detectedCurrency: selectedToken!, - chainId: selectedChainId, - isValid: true, - amount: widget.paymentRequest.amount, - note: widget.paymentRequest.note, - scheme: widget.paymentRequest.scheme, - pjUri: widget.paymentRequest.pjUri, - callbackUrl: widget.paymentRequest.callbackUrl, - callbackMessage: widget.paymentRequest.callbackMessage, - ); - - final newResult = PaymentFlowResult.evmNetworkSelection( - detectionResult, - compatibleWallets: allEVMWallets, - wallet: allEVMWallets.isNotEmpty ? allEVMWallets.first : null, - ); - - widget.paymentViewModel.detectedWalletType = walletType; - - widget.onNext(newResult); - } -} - -class EVMTileWidget extends StatelessWidget { - const EVMTileWidget({ - super.key, - required this.value, - required this.enabled, - required this.onTap, - this.imagePath, - this.color, - }); - - final String value; - final bool enabled; - final VoidCallback? onTap; - final String? imagePath; - final Color? color; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: enabled ? onTap : null, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Theme.of(context).colorScheme.surfaceContainer, - ), - child: Row( - children: [ - if (imagePath != null) ...[ - CakeImageWidget( - imageUrl: imagePath!, - color: color, - width: 20, - height: 20, - ), - const SizedBox(width: 16), - ], - Expanded( - child: Text( - value, - style: Theme.of(context).textTheme.titleMedium!.copyWith( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - Icon( - Icons.keyboard_arrow_down, - size: 24, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart index ac40f7c6a..ca7cf6d1a 100644 --- a/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart +++ b/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart @@ -89,6 +89,13 @@ class _PaymentConfirmationContent extends StatelessWidget { return false; } + /// Checks if flow type is token selection (EVM, Solana, or Tron) + bool _isTokenSelectionFlow(PaymentFlowType type) { + return type == PaymentFlowType.evmNetworkSelection || + type == PaymentFlowType.solanaTokenSelection || + type == PaymentFlowType.tronTokenSelection; + } + @override Widget build(BuildContext context) { return Observer( @@ -127,14 +134,20 @@ class _PaymentConfirmationContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (paymentFlowResult.type == PaymentFlowType.evmNetworkSelection) ...[ + if (_isTokenSelectionFlow(paymentFlowResult.type)) ...[ Stack( clipBehavior: Clip.none, children: [ Image.asset( - paymentFlowResult.addressDetectionResult!.detectedCurrency!.iconPath!, + paymentFlowResult.addressDetectionResult?.detectedCurrency?.iconPath ?? + '', width: 70, height: 70, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.currency_bitcoin, + size: 70, + color: Theme.of(context).colorScheme.onSurface, + ), ), Positioned( bottom: 0, @@ -146,6 +159,11 @@ class _PaymentConfirmationContent extends StatelessWidget { ).iconPath!, width: 32, height: 32, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.account_balance_wallet, + size: 32, + color: Theme.of(context).colorScheme.onSurface, + ), ), ), ], diff --git a/lib/src/widgets/bottom_sheet/token_selection_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/token_selection_bottom_sheet.dart new file mode 100644 index 000000000..bd41ea989 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/token_selection_bottom_sheet.dart @@ -0,0 +1,456 @@ +import 'package:cake_wallet/core/universal_address_detector.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/src/widgets/picker.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/utils/payment_request.dart'; +import 'package:cake_wallet/utils/qr_util.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/utils/token_utilities.dart'; +import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; +import 'package:cake_wallet/wallet_types.g.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; +import 'package:cw_core/currency_for_wallet_type.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; + +class TokenSelectionBottomSheet extends BaseBottomSheet { + TokenSelectionBottomSheet({ + Key? key, + required this.paymentViewModel, + required this.paymentRequest, + required this.onNext, + this.fixedNetwork, + }) : super( + titleText: '', + footerType: FooterType.none, + maxHeight: 900, + ); + + final PaymentViewModel paymentViewModel; + final PaymentRequest paymentRequest; + final Function(PaymentFlowResult) onNext; + final WalletType? fixedNetwork; + + @override + Widget contentWidget(BuildContext context) { + return _TokenSelectionContent( + paymentViewModel: paymentViewModel, + paymentRequest: paymentRequest, + onNext: onNext, + fixedNetwork: fixedNetwork, + ); + } +} + +class _TokenSelectionContent extends StatefulWidget { + const _TokenSelectionContent({ + required this.paymentViewModel, + required this.paymentRequest, + required this.onNext, + this.fixedNetwork, + }); + + final PaymentViewModel paymentViewModel; + final PaymentRequest paymentRequest; + final Function(PaymentFlowResult) onNext; + final WalletType? fixedNetwork; + + @override + State<_TokenSelectionContent> createState() => _TokenSelectionContentState(); +} + +class _TokenSelectionContentState extends State<_TokenSelectionContent> { + WalletType? selectedNetwork; + CryptoCurrency? selectedToken; + bool isLoadingTokens = false; + String? tokenLoadError; + + @override + void initState() { + super.initState(); + selectedNetwork = widget.fixedNetwork ?? WalletType.ethereum; + _autoSelectToken(); + } + + bool get _isNetworkSelectable => widget.fixedNetwork == null || isEVMCompatibleChain(widget.fixedNetwork!); + + Future _autoSelectToken() async { + if (selectedNetwork == null) return; + + setState(() { + isLoadingTokens = true; + tokenLoadError = null; + }); + + try { + final tokens = await TokenUtilities.getAvailableTokensForNetwork( + selectedNetwork!, + ); + + if (tokens.isEmpty) { + setState(() { + tokenLoadError = 'No tokens available'; + isLoadingTokens = false; + }); + return; + } + + setState(() { + selectedToken = tokens.first; + isLoadingTokens = false; + }); + } catch (e) { + printV('Auto-select token error: $e'); + setState(() { + tokenLoadError = 'Failed to load tokens'; + isLoadingTokens = false; + selectedToken = walletTypeToCryptoCurrency(selectedNetwork!); + }); + } + } + + String _getNetworkTitle() { + if (selectedNetwork == null) return S.current.select_network; + return walletTypeToString(selectedNetwork!); + } + + String _getEcosystemTitle() { + if (selectedNetwork == WalletType.solana) { + return 'Solana\n${S.current.address_detected.toLowerCase()}'; + } + if (selectedNetwork == WalletType.tron) { + return 'Tron\n${S.current.address_detected.toLowerCase()}'; + } + return '${S.current.ethereum_ecosystem}\n${S.current.address_detected.toLowerCase()}'; + } + + String _getEcosystemDescription() { + if (selectedNetwork == WalletType.solana) { + return 'Select a token to send on Solana network'; + } + if (selectedNetwork == WalletType.tron) { + return 'Select a token to send on Tron network'; + } + return S.current.evm_ecosystem_description; + } + + String _getNetworkIcon() { + if (selectedNetwork == null) return 'assets/images/eth_chain_mono.svg'; + return getChainMonoImage(selectedNetwork!); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CakeImageWidget( + imageUrl: _getNetworkIcon(), + width: 50, + height: 50, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + _getEcosystemTitle(), + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w900, + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 36), + Text( + _getEcosystemDescription(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.0, + ), + ), + const SizedBox(height: 32), + TokenSelectionTileWidget( + value: _getNetworkTitle(), + imagePath: selectedNetwork != null ? getChainMonoImage(selectedNetwork!) : null, + color: selectedNetwork != null ? Theme.of(context).colorScheme.primary : null, + enabled: _isNetworkSelectable, + onTap: _isNetworkSelectable ? () => _showNetworkSelection(context) : null, + ), + const SizedBox(height: 16), + TokenSelectionTileWidget( + imagePath: selectedToken?.iconPath, + value: selectedToken != null ? selectedToken!.title : S.current.select_token, + enabled: selectedNetwork != null && !isLoadingTokens, + onTap: isLoadingTokens ? null : () => _showTokenSelection(context), + color: selectedToken == null ? Theme.of(context).colorScheme.primary : null, + ), + if (isLoadingTokens) ...[ + const SizedBox(height: 16), + const CircularProgressIndicator(), + ], + if (tokenLoadError != null) ...[ + const SizedBox(height: 16), + Text( + tokenLoadError!, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + const SizedBox(height: 32), + PrimaryButton( + text: S.current.restore_next, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + onPressed: selectedNetwork != null && selectedToken != null && !isLoadingTokens + ? () async => await _handleNext(context) + : null, + ), + const SizedBox(height: 48), + ], + ), + ); + } + + void _showNetworkSelection(BuildContext context) async { + if (!_isNetworkSelectable) return; + + final evmNetworks = + availableWalletTypes.where((walletType) => isEVMCompatibleChain(walletType)).toList(); + final selectedIndex = evmNetworks.indexOf(selectedNetwork ?? WalletType.ethereum); + + await showPopUp( + context: context, + builder: (BuildContext context) { + return Picker( + items: evmNetworks, + displayItem: (WalletType network) => walletTypeToString(network), + selectedAtIndex: selectedIndex, + title: S.current.select_network, + closeOnItemSelected: true, + hasTitleSpacing: true, + images: evmNetworks + .map((network) => CakeImageWidget( + imageUrl: getChainMonoImage(network), + width: 20, + height: 20, + color: Theme.of(context).colorScheme.primary)) + .toList(), + onItemSelected: (WalletType network) { + setState(() { + selectedNetwork = network; + selectedToken = null; + }); + _autoSelectToken(); + }, + ); + }, + ); + } + + void _showTokenSelection(BuildContext context) async { + if (selectedNetwork == null || isLoadingTokens) return; + + setState(() { + isLoadingTokens = true; + }); + + try { + final availableTokens = await TokenUtilities.getAvailableTokensForNetwork( + selectedNetwork!, + ); + + if (availableTokens.isEmpty) { + setState(() { + isLoadingTokens = false; + }); + await showPopUp( + context: context, + builder: (context) => AlertDialog( + title: Text(S.current.error), + content: const Text('No tokens available for this network'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(S.current.ok), + ), + ], + ), + ); + return; + } + + final selectedIndex = selectedToken != null ? availableTokens.indexOf(selectedToken!) : 0; + + setState(() { + isLoadingTokens = false; + }); + + await showPopUp( + context: context, + builder: (BuildContext context) { + return CurrencyPicker( + selectedAtIndex: selectedIndex >= 0 ? selectedIndex : 0, + items: availableTokens.cast(), + hintText: S.current.add_token, + onItemSelected: (Currency currency) { + setState(() { + selectedToken = currency as CryptoCurrency; + }); + }, + ); + }, + ); + } catch (e) { + printV('Error showing token selection: $e'); + setState(() { + isLoadingTokens = false; + tokenLoadError = 'Failed to load tokens'; + }); + } + } + + Future _handleNext(BuildContext context) async { + if (selectedNetwork == null || selectedToken == null) return; + + Navigator.of(context).pop(); + + final compatibleWallets = await widget.paymentViewModel.getWalletsByType(selectedNetwork!); + + PaymentFlowResult newResult; + + if (selectedNetwork == WalletType.solana) { + newResult = PaymentFlowResult.solanaTokenSelection( + AddressDetectionResult( + address: widget.paymentRequest.address, + detectedWalletType: WalletType.solana, + detectedCurrency: selectedToken!, + isValid: true, + amount: widget.paymentRequest.amount, + note: widget.paymentRequest.note, + scheme: widget.paymentRequest.scheme, + pjUri: widget.paymentRequest.pjUri, + callbackUrl: widget.paymentRequest.callbackUrl, + callbackMessage: widget.paymentRequest.callbackMessage, + ), + compatibleWallets: compatibleWallets, + wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null, + ); + } else if (selectedNetwork == WalletType.tron) { + newResult = PaymentFlowResult.tronTokenSelection( + AddressDetectionResult( + address: widget.paymentRequest.address, + detectedWalletType: WalletType.tron, + detectedCurrency: selectedToken!, + isValid: true, + amount: widget.paymentRequest.amount, + note: widget.paymentRequest.note, + scheme: widget.paymentRequest.scheme, + pjUri: widget.paymentRequest.pjUri, + callbackUrl: widget.paymentRequest.callbackUrl, + callbackMessage: widget.paymentRequest.callbackMessage, + ), + compatibleWallets: compatibleWallets, + wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null, + ); + } else { + newResult = PaymentFlowResult.evmNetworkSelection( + AddressDetectionResult( + address: widget.paymentRequest.address, + detectedWalletType: selectedNetwork!, + detectedCurrency: selectedToken!, + isValid: true, + amount: widget.paymentRequest.amount, + note: widget.paymentRequest.note, + scheme: widget.paymentRequest.scheme, + pjUri: widget.paymentRequest.pjUri, + callbackUrl: widget.paymentRequest.callbackUrl, + callbackMessage: widget.paymentRequest.callbackMessage, + ), + compatibleWallets: compatibleWallets, + wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null, + ); + } + + widget.paymentViewModel.detectedWalletType = selectedNetwork!; + widget.onNext(newResult); + } +} + +class TokenSelectionTileWidget extends StatelessWidget { + const TokenSelectionTileWidget({ + super.key, + required this.value, + required this.enabled, + required this.onTap, + this.imagePath, + this.color, + }); + + final String value; + final bool enabled; + final VoidCallback? onTap; + final String? imagePath; + final Color? color; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(12), + child: Opacity( + opacity: enabled ? 1.0 : 0.6, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surfaceContainer, + ), + child: Row( + children: [ + if (imagePath != null) ...[ + CakeImageWidget( + imageUrl: imagePath!, + color: color, + width: 20, + height: 20, + ), + const SizedBox(width: 16), + ], + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (enabled) + Icon( + Icons.keyboard_arrow_down, + size: 24, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/utils/token_utilities.dart b/lib/utils/token_utilities.dart index f08797e32..4ba52e6b0 100644 --- a/lib/utils/token_utilities.dart +++ b/lib/utils/token_utilities.dart @@ -22,18 +22,13 @@ class TokenUtilities { final unique = []; for (final wallet in evmWallets) { - final allChains = evm!.getAllChains(); + final chain = getTokenNameBasedOnWalletType(wallet.type); + final box = await _openEvmTokensBoxFor(wallet); - for (final chainInfo in allChains) { - final chainId = chainInfo.chainId; - final chain = getTokenNameBasedOnWalletType(wallet.type, chainId: chainId); - final box = await _openEvmTokensBoxFor(wallet, chainId); - - for (final t in box.values.where((t) => t.enabled)) { - final key = '$chain|${t.contractAddress.toLowerCase()}'; - if (seen.add(key)) { - unique.add(t); - } + for (final t in box.values.where((t) => t.enabled)) { + final key = '$chain|${t.contractAddress.toLowerCase()}'; + if (seen.add(key)) { + unique.add(t); } } } @@ -93,6 +88,7 @@ class TokenUtilities { case WalletType.ethereum: case WalletType.polygon: case WalletType.base: + case WalletType.arbitrum: final tokens = await loadAllUniqueEvmTokens(); for (final t in tokens) { if (t.contractAddress.toLowerCase() == lower) return t; @@ -115,9 +111,9 @@ class TokenUtilities { } } - static Future> _openEvmTokensBoxFor(WalletInfo walletInfo, int chainId) async { + static Future> _openEvmTokensBoxFor(WalletInfo walletInfo) async { final walletKey = walletInfo.name.replaceAll(' ', '_'); - final boxName = _getErc20TokensBoxName(walletKey, chainId); + final boxName = _getErc20TokensBoxName(walletKey, walletInfo.type); if (CakeHive.isBoxOpen(boxName)) { return CakeHive.box(boxName); @@ -125,13 +121,13 @@ class TokenUtilities { return CakeHive.openBox(boxName); } - static String _getErc20TokensBoxName(String sanitizedName, int chainId) { - return switch (chainId) { - 1 => "${sanitizedName}_${Erc20Token.ethereumBoxName}", - 137 => "${sanitizedName}_${Erc20Token.polygonBoxName}", - 8453 => "${sanitizedName}_${Erc20Token.baseBoxName}", - 42161 => "${sanitizedName}_${Erc20Token.arbitrumBoxName}", - _ => "${sanitizedName}_${Erc20Token.ethereumBoxName}", + static String _getErc20TokensBoxName(String walletKey, WalletType walletType) { + return switch (walletType) { + WalletType.ethereum => '${walletKey}_${Erc20Token.ethereumBoxName}', + WalletType.polygon => '${walletKey}_${Erc20Token.polygonBoxName}', + WalletType.base => '${walletKey}_${Erc20Token.baseBoxName}', + WalletType.arbitrum => '${walletKey}_${Erc20Token.arbitrumBoxName}', + _ => '${walletKey}_${Erc20Token.ethereumBoxName}', }; } @@ -172,6 +168,8 @@ class TokenUtilities { title == 'ethereum' || title == 'matic' || title == 'polygon' || + title == 'base' || + title == 'arbitrum' || title == 'bnb' || title == 'bsc' || title == 'avax' || @@ -186,14 +184,8 @@ class TokenUtilities { final title = currency.title.toLowerCase(); // Only check EVM registry for currencies that might be EVM-related - final isPotentialEVM = title == 'eth' || - title == 'ethereum' || - title == 'polygon' || - title == 'matic' || - title == 'base' || - title == 'arbitrum' || - (tag != null && (tag == 'ETH' || tag == 'POL' || tag == 'BASE' || tag == 'ARB')) || - isNativeToken(currency); + final isPotentialEVM = isNativeToken(currency) || + (tag != null && (tag == 'ETH' || tag == 'POL' || tag == 'BASE' || tag == 'ARB')); if (isPotentialEVM) { // Try by tag first if available (e.g., 'POL', 'BASE', 'ARB') @@ -232,56 +224,90 @@ class TokenUtilities { return 1; } - static bool _shouldAddToken( - List existingTokens, - CryptoCurrency token, - Set addedAddresses, - ) { - if (token is Erc20Token) { - final address = token.contractAddress.toLowerCase(); - if (addedAddresses.contains(address)) { - return false; - } - if (existingTokens.any((existing) => _matchesCurrency(existing, token))) { - return false; - } - return true; - } - - return !existingTokens.any((existing) => _matchesCurrency(existing, token)); - } - - static bool _matchesCurrency(CryptoCurrency a, CryptoCurrency b) { - return a.title.toUpperCase() == b.title.toUpperCase() && - (a.tag?.toUpperCase() == b.tag?.toUpperCase()); - } - - static Future> getAvailableTokensForChainId(int chainId) async { - // Get native currency for the chain - final baseCurrency = getCryptoCurrencyByChainId(chainId); - final allTokens = []; + static Future> getAvailableTokensForNetwork( + WalletType network, + ) async { + final baseCurrency = walletTypeToCryptoCurrency(network); + final allTokens = [baseCurrency]; final addedAddresses = {}; - allTokens.add(baseCurrency); + // Handle EVM networks + if (isEVMCompatibleChain(network)) { + // First, collect all user tokens + final userTokens = await _getUserTokensForNetwork(baseCurrency); + for (final token in userTokens) { + if (token is Erc20Token) { + final address = token.contractAddress.toLowerCase(); + if (addedAddresses.add(address)) { + allTokens.add(token); + } + } + } - // Add currencies that match this chain - for (final currency in CryptoCurrency.all) { - final matches = (baseCurrency.tag == null && baseCurrency.title == currency.tag) || - (baseCurrency.tag != null && - currency.tag?.toLowerCase() == baseCurrency.tag?.toLowerCase()); + // Then add tokens from CryptoCurrency.all that don't duplicate user tokens + for (final currency in CryptoCurrency.all) { + // Match by tag for POL/BASE, match by title==tag for ETH + final matches = (baseCurrency.tag == null && baseCurrency.title == currency.tag) || + (baseCurrency.tag != null && + currency.tag?.toLowerCase() == baseCurrency.tag?.toLowerCase()); - if (matches && _shouldAddToken(allTokens, currency, addedAddresses)) { - allTokens.add(currency); + if (matches) { + if (currency is Erc20Token) { + final address = currency.contractAddress.toLowerCase(); + if (addedAddresses.add(address)) { + allTokens.add(currency); + } + } else if (!allTokens.any((t) => _matchesCurrency(t, currency))) { + allTokens.add(currency); + } + } } } - // Add user tokens for this chain - final userTokens = await _getUserTokensForChainId(chainId); - for (final token in userTokens) { - if (_shouldAddToken(allTokens, token, addedAddresses)) { - allTokens.add(token); - if (token is Erc20Token) { - addedAddresses.add(token.contractAddress.toLowerCase()); + // Handle Solana network + else if (network == WalletType.solana) { + final userSolTokens = await loadAllUniqueSolTokens(); + for (final token in userSolTokens) { + final mintAddress = token.mintAddress.toLowerCase(); + if (addedAddresses.add(mintAddress)) { + allTokens.add(token); + } + } + + for (final currency in CryptoCurrency.all) { + if (currency.tag?.toLowerCase() == 'sol') { + if (currency is SPLToken) { + final mintAddress = currency.mintAddress.toLowerCase(); + if (addedAddresses.add(mintAddress)) { + allTokens.add(currency); + } + } else if (!allTokens.any((t) => _matchesCurrency(t, currency))) { + allTokens.add(currency); + } + } + } + } + + // Handle Tron network + else if (network == WalletType.tron) { + final userTronTokens = await loadAllUniqueTronTokens(); + for (final token in userTronTokens) { + final contractAddress = token.contractAddress.toLowerCase(); + if (addedAddresses.add(contractAddress)) { + allTokens.add(token); + } + } + + for (final currency in CryptoCurrency.all) { + if (currency.tag?.toLowerCase() == 'trx') { + if (currency is TronToken) { + final contractAddress = currency.contractAddress.toLowerCase(); + if (addedAddresses.add(contractAddress)) { + allTokens.add(currency); + } + } else if (!allTokens.any((t) => _matchesCurrency(t, currency))) { + allTokens.add(currency); + } } } } @@ -289,19 +315,33 @@ class TokenUtilities { return allTokens; } - static Future> _getUserTokensForChainId(int chainId) async { - final allWi = await WalletInfo.getAll(); - final evmWallets = allWi.where((w) => isEVMCompatibleChain(w.type)); + static bool _matchesCurrency(CryptoCurrency a, CryptoCurrency b) { + return a.title.toUpperCase() == b.title.toUpperCase() && + (a.tag?.toUpperCase() == b.tag?.toUpperCase()); + } - final tokens = []; - for (final wallet in evmWallets) { - final box = await _openEvmTokensBoxFor(wallet, chainId); + static Future> _getUserTokensForNetwork(CryptoCurrency baseCurrency) async { + final walletType = cryptoCurrencyToWalletType(baseCurrency); + if (walletType == null) return []; - for (final t in box.values.where((t) => t.enabled)) { - tokens.add(t); - } + if (isEVMCompatibleChain(walletType)) { + final tokens = await TokenUtilities.loadAllUniqueEvmTokens(); + + return tokens.where((token) { + if (baseCurrency.tag == null) return token.tag == baseCurrency.title; + + return token.tag?.toLowerCase() == baseCurrency.tag?.toLowerCase(); + }).toList(); } - return tokens.cast(); + if (walletType == WalletType.solana) { + return await loadAllUniqueSolTokens(); + } + + if (walletType == WalletType.tron) { + return await loadAllUniqueTronTokens(); + } + + return []; } } diff --git a/lib/view_model/payment/payment_view_model.dart b/lib/view_model/payment/payment_view_model.dart index a31c5d7bd..394c7f82f 100644 --- a/lib/view_model/payment/payment_view_model.dart +++ b/lib/view_model/payment/payment_view_model.dart @@ -76,70 +76,28 @@ abstract class PaymentViewModelBase with Store { final currentWallet = appStore.wallet; - if (isEVMCompatibleChain(detectedWalletType!)) { - final isRawEvmInput = !addressData.contains(':') && _isEVMAddress(detectionResult.address); - - // Check if the current wallet is also EVM - if (currentWallet != null && isEVMCompatibleChain(currentWallet.type)) { - final currentChainId = evm!.getSelectedChainId(currentWallet); - final detectedChainIdValue = this.detectedChainId; - - if (detectedChainIdValue != null && currentChainId != null) { - // For raw EVM address input that only defaulted to chainId 1, - // always force the EVM ecosystem bottom sheet so the user - // can pick the actual network and token. - if (isRawEvmInput && detectedChainIdValue == 1) { - final allEVMWallets = await getEVMCompatibleWallets(); - - final currentWalletInfo = currentWallet.walletInfo; - - final otherEVMWallets = - allEVMWallets.where((w) => w.name != currentWallet.name).toList(); - - return PaymentFlowResult.evmNetworkSelection( - detectionResult, - compatibleWallets: otherEVMWallets, - wallet: currentWalletInfo, - ); - } - - if (detectedChainIdValue == currentChainId) { - return PaymentFlowResult.currentWalletCompatible(); - } - - final allEVMWallets = await getEVMCompatibleWallets(); - - final currentWalletInfo = currentWallet.walletInfo; - - final otherEVMWallets = - allEVMWallets.where((w) => w.name != currentWallet.name).toList(); - - return PaymentFlowResult.evmNetworkSelection( - detectionResult, - compatibleWallets: otherEVMWallets, - wallet: currentWalletInfo, - ); - } - } - - // If the current wallet is not EVM or the chainId comparison failed - // We proceed with other checks - if (!addressData.contains(':') && _isEVMAddress(detectionResult.address)) { - final allEVMWallets = await getEVMCompatibleWallets(); - return PaymentFlowResult.evmNetworkSelection( - detectionResult, - compatibleWallets: allEVMWallets, - ); - } - - // For EVM URIs, show network selection - final allEVMWallets = await getEVMCompatibleWallets(); - return PaymentFlowResult.evmNetworkSelection( + if (detectedWalletType == WalletType.solana) { + final compatibleWallets = await getWalletsByType(WalletType.solana); + return PaymentFlowResult.solanaTokenSelection( detectionResult, - compatibleWallets: allEVMWallets, + compatibleWallets: compatibleWallets, + wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null, ); } + if (detectedWalletType == WalletType.tron) { + final compatibleWallets = await getWalletsByType(WalletType.tron); + return PaymentFlowResult.tronTokenSelection( + detectionResult, + compatibleWallets: compatibleWallets, + wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null, + ); + } + + if (isEVMCompatibleChain(detectedWalletType!)) { + return PaymentFlowResult.evmNetworkSelection(detectionResult); + } + if (currentWallet != null && currentWallet.type == detectedWalletType) { return PaymentFlowResult.currentWalletCompatible(); } @@ -172,10 +130,6 @@ abstract class PaymentViewModelBase with Store { await evm!.selectChain(appStore.wallet!, detectedChainId!, node: node); } - bool _isEVMAddress(String address) { - return RegExp(r'^0x[a-fA-F0-9]{40}$').hasMatch(address); - } - Future> getWalletsByType(WalletType walletType) async { return (await WalletInfo.getAll()).where((wallet) => wallet.type == walletType).toList(); } @@ -230,6 +184,34 @@ class PaymentFlowResult { ); } + /// Solana address detected - needs token selection + factory PaymentFlowResult.solanaTokenSelection( + AddressDetectionResult addressDetectionResult, { + List? compatibleWallets, + WalletInfo? wallet, + }) => + PaymentFlowResult._( + type: PaymentFlowType.solanaTokenSelection, + addressDetectionResult: addressDetectionResult, + walletType: WalletType.solana, + wallets: compatibleWallets ?? [], + wallet: wallet, + ); + + /// Tron address detected - needs token selection + factory PaymentFlowResult.tronTokenSelection( + AddressDetectionResult addressDetectionResult, { + List? compatibleWallets, + WalletInfo? wallet, + }) => + PaymentFlowResult._( + type: PaymentFlowType.tronTokenSelection, + addressDetectionResult: addressDetectionResult, + walletType: WalletType.tron, + wallets: compatibleWallets ?? [], + wallet: wallet, + ); + /// Current wallet is compatible factory PaymentFlowResult.currentWalletCompatible() => PaymentFlowResult._(type: PaymentFlowType.currentWalletCompatible); @@ -308,7 +290,9 @@ class PaymentFlowResult { PaymentFlowResult._(type: PaymentFlowType.incompatible, message: message); CryptoCurrency? get detectedCurrency { - if (type == PaymentFlowType.evmNetworkSelection) { + if (type == PaymentFlowType.evmNetworkSelection || + type == PaymentFlowType.solanaTokenSelection || + type == PaymentFlowType.tronTokenSelection) { return addressDetectionResult?.detectedCurrency; } if (walletType != null) { @@ -328,6 +312,8 @@ enum PaymentFlowType { multipleWallets, noWallets, evmNetworkSelection, + solanaTokenSelection, + tronTokenSelection, error, incompatible, }