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
This commit is contained in:
David Adegoke
2026-01-18 14:34:03 +01:00
committed by GitHub
parent 6cb28d4648
commit 5bb779d115
6 changed files with 673 additions and 485 deletions
+25 -6
View File
@@ -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<SendCard> with AutomaticKeepAliveClientMixin<S
);
break;
case PaymentFlowType.evmNetworkSelection:
await _showEVMPaymentFlow(
await _showTokenSelectionFlow(
paymentViewModel,
walletSwitcherViewModel,
paymentRequest,
fixedNetwork: result.walletType,
);
break;
case PaymentFlowType.solanaTokenSelection:
await _showTokenSelectionFlow(
paymentViewModel,
walletSwitcherViewModel,
paymentRequest,
fixedNetwork: WalletType.solana,
);
break;
case PaymentFlowType.tronTokenSelection:
await _showTokenSelectionFlow(
paymentViewModel,
walletSwitcherViewModel,
paymentRequest,
fixedNetwork: WalletType.tron,
);
break;
@@ -238,11 +255,12 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
);
}
Future<void> _showEVMPaymentFlow(
Future<void> _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<SendCard> with AutomaticKeepAliveClientMixin<S
isDismissible: true,
isScrollControlled: true,
builder: (BuildContext context) {
return EVMPaymentFlowBottomSheet(
return TokenSelectionBottomSheet(
paymentViewModel: paymentViewModel,
paymentRequest: paymentRequest,
fixedNetwork: fixedNetwork,
onNext: (PaymentFlowResult newResult) {
final selectedChainId = newResult.chainId;
final isCompatible = selectedChainId == evm!.getSelectedChainId(sendViewModel.wallet);
@@ -1,331 +0,0 @@
import 'package:cake_wallet/core/universal_address_detector.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/generated/i18n.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:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
class EVMPaymentFlowBottomSheet extends BaseBottomSheet {
EVMPaymentFlowBottomSheet({
Key? key,
required this.paymentViewModel,
required this.paymentRequest,
required this.onNext,
}) : super(
titleText: '',
footerType: FooterType.none,
maxHeight: 900,
);
final PaymentViewModel paymentViewModel;
final PaymentRequest paymentRequest;
final Function(PaymentFlowResult) onNext;
@override
Widget contentWidget(BuildContext context) {
return _EVMPaymentFlowContent(
paymentViewModel: paymentViewModel,
paymentRequest: paymentRequest,
onNext: onNext,
);
}
}
class _EVMPaymentFlowContent extends StatefulWidget {
const _EVMPaymentFlowContent({
required this.paymentViewModel,
required this.paymentRequest,
required this.onNext,
});
final PaymentViewModel paymentViewModel;
final PaymentRequest paymentRequest;
final Function(PaymentFlowResult) onNext;
@override
State<_EVMPaymentFlowContent> createState() => _EVMPaymentFlowContentState();
}
class _EVMPaymentFlowContentState extends State<_EVMPaymentFlowContent> {
int? selectedChainId;
CryptoCurrency? selectedToken;
@override
void initState() {
super.initState();
selectedChainId = widget.paymentViewModel.detectedChainId ?? 1;
_autoSelectToken();
}
Future<void> _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<void>(
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<void>(
context: context,
builder: (BuildContext context) {
return CurrencyPicker(
selectedAtIndex: selectedIndex >= 0 ? selectedIndex : 0,
items: availableTokens.cast<Currency>(),
hintText: S.current.add_token,
onItemSelected: (Currency currency) {
setState(() {
selectedToken = currency as CryptoCurrency;
});
},
);
},
);
} catch (e) {
printV('Error showing token selection: $e');
}
}
Future<void> _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,
),
],
),
),
);
}
}
@@ -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,
),
),
),
],
@@ -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<void> _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<void>(
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<void>(
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<void>(
context: context,
builder: (BuildContext context) {
return CurrencyPicker(
selectedAtIndex: selectedIndex >= 0 ? selectedIndex : 0,
items: availableTokens.cast<Currency>(),
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<void> _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,
),
],
),
),
),
);
}
}
+121 -81
View File
@@ -22,18 +22,13 @@ class TokenUtilities {
final unique = <Erc20Token>[];
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<Box<Erc20Token>> _openEvmTokensBoxFor(WalletInfo walletInfo, int chainId) async {
static Future<Box<Erc20Token>> _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<Erc20Token>(boxName);
@@ -125,13 +121,13 @@ class TokenUtilities {
return CakeHive.openBox<Erc20Token>(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<CryptoCurrency> existingTokens,
CryptoCurrency token,
Set<String> 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<List<CryptoCurrency>> getAvailableTokensForChainId(int chainId) async {
// Get native currency for the chain
final baseCurrency = getCryptoCurrencyByChainId(chainId);
final allTokens = <CryptoCurrency>[];
static Future<List<CryptoCurrency>> getAvailableTokensForNetwork(
WalletType network,
) async {
final baseCurrency = walletTypeToCryptoCurrency(network);
final allTokens = <CryptoCurrency>[baseCurrency];
final addedAddresses = <String>{};
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<List<CryptoCurrency>> _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 = <Erc20Token>[];
for (final wallet in evmWallets) {
final box = await _openEvmTokensBoxFor(wallet, chainId);
static Future<List<CryptoCurrency>> _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<CryptoCurrency>();
if (walletType == WalletType.solana) {
return await loadAllUniqueSolTokens();
}
if (walletType == WalletType.tron) {
return await loadAllUniqueTronTokens();
}
return [];
}
}
+51 -65
View File
@@ -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<List<WalletInfo>> 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<WalletInfo>? 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<WalletInfo>? 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,
}