dev
dart 497 lines 16.4 KB
Raw
1 import 'package:cake_wallet/core/universal_address_detector.dart';
2 import 'package:cake_wallet/generated/i18n.dart';
3 import 'package:cake_wallet/reactions/wallet_connect.dart';
4 import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
5 import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
6 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
7 import 'package:cake_wallet/src/widgets/picker.dart';
8 import 'package:cake_wallet/src/widgets/primary_button.dart';
9 import 'package:cake_wallet/utils/payment_request.dart';
10 import 'package:cake_wallet/utils/qr_util.dart';
11 import 'package:cake_wallet/utils/show_pop_up.dart';
12 import 'package:cake_wallet/utils/token_utilities.dart';
13 import 'package:cake_wallet/view_model/payment/payment_view_model.dart';
14 import 'package:cake_wallet/wallet_types.g.dart';
15 import 'package:cw_core/crypto_currency.dart';
16 import 'package:cw_core/currency.dart';
17 import 'package:cw_core/currency_for_wallet_type.dart';
18 import 'package:cw_core/erc20_token.dart';
19 import 'package:cw_core/spl_token.dart';
20 import 'package:cw_core/tron_token.dart';
21 import 'package:cw_core/utils/print_verbose.dart';
22 import 'package:cw_core/wallet_type.dart';
23 import 'package:flutter/material.dart';
24
25 class TokenSelectionBottomSheet extends BaseBottomSheet {
26 TokenSelectionBottomSheet({
27 Key? key,
28 required this.paymentViewModel,
29 required this.paymentRequest,
30 required this.onNext,
31 this.fixedNetwork,
32 }) : super(
33 titleText: '',
34 footerType: FooterType.none,
35 maxHeight: 900,
36 );
37
38 final PaymentViewModel paymentViewModel;
39 final PaymentRequest paymentRequest;
40 final Function(PaymentFlowResult) onNext;
41 final WalletType? fixedNetwork;
42
43 @override
44 Widget contentWidget(BuildContext context) {
45 return _TokenSelectionContent(
46 paymentViewModel: paymentViewModel,
47 paymentRequest: paymentRequest,
48 onNext: onNext,
49 fixedNetwork: fixedNetwork,
50 );
51 }
52 }
53
54 class _TokenSelectionContent extends StatefulWidget {
55 const _TokenSelectionContent({
56 required this.paymentViewModel,
57 required this.paymentRequest,
58 required this.onNext,
59 this.fixedNetwork,
60 });
61
62 final PaymentViewModel paymentViewModel;
63 final PaymentRequest paymentRequest;
64 final Function(PaymentFlowResult) onNext;
65 final WalletType? fixedNetwork;
66
67 @override
68 State<_TokenSelectionContent> createState() => _TokenSelectionContentState();
69 }
70
71 class _TokenSelectionContentState extends State<_TokenSelectionContent> {
72 WalletType? selectedNetwork;
73 CryptoCurrency? selectedToken;
74 bool isLoadingTokens = false;
75 String? tokenLoadError;
76
77 @override
78 void initState() {
79 super.initState();
80 final baseNetwork = widget.fixedNetwork ?? WalletType.ethereum;
81 selectedNetwork = _resolveGenericETHDetectionResultToSpecificChain(
82 baseNetwork, widget.paymentRequest.scheme.isNotEmpty);
83 _autoSelectToken();
84 }
85
86 bool get _isNetworkSelectable =>
87 widget.fixedNetwork == null || isEVMCompatibleChain(widget.fixedNetwork!);
88
89 CryptoCurrency? _tokenMatchingContract(List<CryptoCurrency> tokens, String? rawContract) {
90 final trimmed = rawContract?.trim();
91 if (trimmed == null || trimmed.isEmpty) return null;
92
93 for (final token in tokens) {
94 if (token is Erc20Token && token.contractAddress.toLowerCase() == trimmed.toLowerCase()) {
95 return token;
96 } else if (token is SPLToken && token.mintAddress == trimmed) {
97 return token;
98 } else if (token is TronToken && token.contractAddress == trimmed) {
99 return token;
100 }
101 }
102 return null;
103 }
104
105 WalletType _resolveGenericETHDetectionResultToSpecificChain(
106 WalletType network, bool hasURIScheme) {
107 if (hasURIScheme || network != WalletType.ethereum) return network;
108
109 final current = widget.paymentViewModel.currentWalletType;
110 if (isEVMCompatibleChain(current)) return current;
111 return network;
112 }
113
114 Future<void> _autoSelectToken() async {
115 final initialNetwork = selectedNetwork;
116 if (initialNetwork == null || !mounted) return;
117
118 final network = _resolveGenericETHDetectionResultToSpecificChain(
119 initialNetwork, widget.paymentRequest.scheme.isNotEmpty);
120
121 setState(() {
122 selectedNetwork = network;
123 isLoadingTokens = true;
124 tokenLoadError = null;
125 });
126
127 try {
128 final tokens = await TokenUtilities.getAvailableTokensForNetwork(network);
129 if (!mounted) return;
130
131 if (tokens.isEmpty) {
132 setState(() {
133 tokenLoadError = 'No tokens available';
134 isLoadingTokens = false;
135 selectedToken = null;
136 });
137 return;
138 }
139
140 final matchedToken =
141 _tokenMatchingContract(tokens, widget.paymentRequest.contractAddress) ?? tokens.first;
142
143 setState(() {
144 selectedToken = matchedToken;
145 isLoadingTokens = false;
146 tokenLoadError = null;
147 });
148 } catch (e) {
149 printV('Auto-select token error: $e');
150 if (!mounted) return;
151 setState(() {
152 tokenLoadError = 'Failed to load tokens';
153 isLoadingTokens = false;
154 selectedToken = walletTypeToCryptoCurrency(network);
155 });
156 }
157 }
158
159 String _getNetworkTitle() {
160 if (selectedNetwork == null) return S.current.select_network;
161 return walletTypeToString(selectedNetwork!);
162 }
163
164 String _getEcosystemTitle() {
165 if (selectedNetwork == WalletType.solana) {
166 return 'Solana\n${S.current.address_detected.toLowerCase()}';
167 }
168 if (selectedNetwork == WalletType.tron) {
169 return 'Tron\n${S.current.address_detected.toLowerCase()}';
170 }
171 return '${S.current.ethereum_ecosystem}\n${S.current.address_detected.toLowerCase()}';
172 }
173
174 String _getEcosystemDescription() {
175 if (selectedNetwork == WalletType.solana) {
176 return 'Select a token to send on Solana network';
177 }
178 if (selectedNetwork == WalletType.tron) {
179 return 'Select a token to send on Tron network';
180 }
181 return S.current.evm_ecosystem_description;
182 }
183
184 String _getNetworkIcon() {
185 if (selectedNetwork == null) return 'assets/images/eth_chain_mono.svg';
186 return getChainMonoImage(selectedNetwork!);
187 }
188
189 @override
190 Widget build(BuildContext context) {
191 return Container(
192 padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
193 child: Column(
194 mainAxisSize: MainAxisSize.min,
195 children: [
196 CakeImageWidget(
197 imageUrl: _getNetworkIcon(),
198 width: 50,
199 height: 50,
200 color: Theme.of(context).colorScheme.onSurface,
201 ),
202 const SizedBox(height: 24),
203 Padding(
204 padding: const EdgeInsets.symmetric(horizontal: 24),
205 child: Text(
206 _getEcosystemTitle(),
207 style: Theme.of(context).textTheme.titleMedium!.copyWith(
208 fontSize: 20,
209 fontWeight: FontWeight.w900,
210 color: Theme.of(context).colorScheme.onSurface,
211 ),
212 textAlign: TextAlign.center,
213 ),
214 ),
215 const SizedBox(height: 36),
216 Text(
217 _getEcosystemDescription(),
218 textAlign: TextAlign.center,
219 style: Theme.of(context).textTheme.bodyMedium!.copyWith(
220 fontSize: 16,
221 fontWeight: FontWeight.w600,
222 color: Theme.of(context).colorScheme.onSurfaceVariant,
223 letterSpacing: 0.0,
224 ),
225 ),
226 const SizedBox(height: 32),
227 TokenSelectionTileWidget(
228 value: _getNetworkTitle(),
229 imagePath: selectedNetwork != null ? getChainMonoImage(selectedNetwork!) : null,
230 color: selectedNetwork != null ? Theme.of(context).colorScheme.primary : null,
231 enabled: _isNetworkSelectable,
232 onTap: _isNetworkSelectable ? () => _showNetworkSelection(context) : null,
233 ),
234 const SizedBox(height: 16),
235 TokenSelectionTileWidget(
236 imagePath: selectedToken?.iconPath,
237 value: selectedToken != null ? selectedToken!.title : S.current.select_token,
238 enabled: selectedNetwork != null && !isLoadingTokens,
239 onTap: isLoadingTokens ? null : () => _showTokenSelection(context),
240 color: selectedToken == null ? Theme.of(context).colorScheme.primary : null,
241 ),
242 if (isLoadingTokens) ...[
243 const SizedBox(height: 16),
244 const CircularProgressIndicator(),
245 ],
246 if (tokenLoadError != null) ...[
247 const SizedBox(height: 16),
248 Text(
249 tokenLoadError!,
250 style: Theme.of(context).textTheme.bodySmall!.copyWith(
251 color: Theme.of(context).colorScheme.error,
252 ),
253 ),
254 ],
255 const SizedBox(height: 32),
256 PrimaryButton(
257 text: S.current.restore_next,
258 color: Theme.of(context).colorScheme.primary,
259 textColor: Theme.of(context).colorScheme.onPrimary,
260 onPressed: selectedNetwork != null && selectedToken != null && !isLoadingTokens
261 ? () async => await _handleNext(context)
262 : null,
263 ),
264 const SizedBox(height: 48),
265 ],
266 ),
267 );
268 }
269
270 void _showNetworkSelection(BuildContext context) async {
271 if (!_isNetworkSelectable) return;
272
273 final evmNetworks =
274 availableWalletTypes.where((walletType) => isEVMCompatibleChain(walletType)).toList();
275 final selectedIndex = evmNetworks.indexOf(selectedNetwork ?? WalletType.ethereum);
276
277 await showPopUp<void>(
278 context: context,
279 builder: (BuildContext context) {
280 return Picker(
281 items: evmNetworks,
282 displayItem: (WalletType network) => walletTypeToString(network),
283 selectedAtIndex: selectedIndex,
284 title: S.current.select_network,
285 closeOnItemSelected: true,
286 hasTitleSpacing: true,
287 images: evmNetworks
288 .map((network) => CakeImageWidget(
289 imageUrl: getChainMonoImage(network),
290 width: 20,
291 height: 20,
292 color: Theme.of(context).colorScheme.primary))
293 .toList(),
294 onItemSelected: (WalletType network) {
295 setState(() {
296 selectedNetwork = network;
297 selectedToken = null;
298 });
299 _autoSelectToken();
300 },
301 );
302 },
303 );
304 }
305
306 void _showTokenSelection(BuildContext context) async {
307 if (selectedNetwork == null || isLoadingTokens) return;
308
309 setState(() {
310 isLoadingTokens = true;
311 });
312
313 try {
314 final availableTokens = await TokenUtilities.getAvailableTokensForNetwork(
315 selectedNetwork!,
316 );
317
318 if (availableTokens.isEmpty) {
319 setState(() {
320 isLoadingTokens = false;
321 });
322 await showPopUp<void>(
323 context: context,
324 builder: (context) => AlertDialog(
325 title: Text(S.current.error),
326 content: const Text('No tokens available for this network'),
327 actions: [
328 TextButton(
329 onPressed: () => Navigator.of(context).pop(),
330 child: Text(S.current.ok),
331 ),
332 ],
333 ),
334 );
335 return;
336 }
337
338 final selectedIndex = selectedToken != null ? availableTokens.indexOf(selectedToken!) : 0;
339
340 setState(() {
341 isLoadingTokens = false;
342 });
343
344 await showPopUp<void>(
345 context: context,
346 builder: (BuildContext context) {
347 return CurrencyPicker(
348 selectedAtIndex: selectedIndex >= 0 ? selectedIndex : 0,
349 items: availableTokens.cast<Currency>(),
350 hintText: S.current.add_token,
351 onItemSelected: (Currency currency) {
352 setState(() {
353 selectedToken = currency as CryptoCurrency;
354 });
355 },
356 );
357 },
358 );
359 } catch (e) {
360 printV('Error showing token selection: $e');
361 setState(() {
362 isLoadingTokens = false;
363 tokenLoadError = 'Failed to load tokens';
364 });
365 }
366 }
367
368 Future<void> _handleNext(BuildContext context) async {
369 if (selectedNetwork == null || selectedToken == null) return;
370
371 Navigator.of(context).pop();
372
373 final compatibleWallets = await widget.paymentViewModel.getWalletsByType(selectedNetwork!);
374
375 PaymentFlowResult newResult;
376
377 if (selectedNetwork == WalletType.solana) {
378 newResult = PaymentFlowResult.solanaTokenSelection(
379 AddressDetectionResult(
380 address: widget.paymentRequest.address,
381 detectedWalletType: WalletType.solana,
382 detectedCurrency: selectedToken!,
383 isValid: true,
384 amount: widget.paymentRequest.resolveTokenAmount(selectedToken!),
385 note: widget.paymentRequest.note,
386 scheme: widget.paymentRequest.scheme,
387 pjUri: widget.paymentRequest.pjUri,
388 callbackUrl: widget.paymentRequest.callbackUrl,
389 callbackMessage: widget.paymentRequest.callbackMessage,
390 ),
391 compatibleWallets: compatibleWallets,
392 wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null,
393 );
394 } else if (selectedNetwork == WalletType.tron) {
395 newResult = PaymentFlowResult.tronTokenSelection(
396 AddressDetectionResult(
397 address: widget.paymentRequest.address,
398 detectedWalletType: WalletType.tron,
399 detectedCurrency: selectedToken!,
400 isValid: true,
401 amount: widget.paymentRequest.resolveTokenAmount(selectedToken!),
402 note: widget.paymentRequest.note,
403 scheme: widget.paymentRequest.scheme,
404 pjUri: widget.paymentRequest.pjUri,
405 callbackUrl: widget.paymentRequest.callbackUrl,
406 callbackMessage: widget.paymentRequest.callbackMessage,
407 ),
408 compatibleWallets: compatibleWallets,
409 wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null,
410 );
411 } else {
412 newResult = PaymentFlowResult.evmNetworkSelection(
413 AddressDetectionResult(
414 address: widget.paymentRequest.address,
415 detectedWalletType: selectedNetwork!,
416 detectedCurrency: selectedToken!,
417 isValid: true,
418 amount: widget.paymentRequest.resolveTokenAmount(selectedToken!),
419 note: widget.paymentRequest.note,
420 scheme: widget.paymentRequest.scheme,
421 pjUri: widget.paymentRequest.pjUri,
422 callbackUrl: widget.paymentRequest.callbackUrl,
423 callbackMessage: widget.paymentRequest.callbackMessage,
424 ),
425 compatibleWallets: compatibleWallets,
426 wallet: compatibleWallets.isNotEmpty ? compatibleWallets.first : null,
427 );
428 }
429
430 widget.paymentViewModel.detectedWalletType = selectedNetwork!;
431 widget.onNext(newResult);
432 }
433 }
434
435 class TokenSelectionTileWidget extends StatelessWidget {
436 const TokenSelectionTileWidget({
437 super.key,
438 required this.value,
439 required this.enabled,
440 required this.onTap,
441 this.imagePath,
442 this.color,
443 });
444
445 final String value;
446 final bool enabled;
447 final VoidCallback? onTap;
448 final String? imagePath;
449 final Color? color;
450
451 @override
452 Widget build(BuildContext context) {
453 return InkWell(
454 onTap: enabled ? onTap : null,
455 borderRadius: BorderRadius.circular(12),
456 child: Opacity(
457 opacity: enabled ? 1.0 : 0.6,
458 child: Container(
459 padding: const EdgeInsets.all(16),
460 decoration: BoxDecoration(
461 borderRadius: BorderRadius.circular(12),
462 color: Theme.of(context).colorScheme.surfaceContainer,
463 ),
464 child: Row(
465 children: [
466 if (imagePath != null) ...[
467 CakeImageWidget(
468 imageUrl: imagePath!,
469 color: color,
470 width: 20,
471 height: 20,
472 ),
473 const SizedBox(width: 16),
474 ],
475 Expanded(
476 child: Text(
477 value,
478 style: Theme.of(context).textTheme.titleMedium!.copyWith(
479 fontSize: 14,
480 fontWeight: FontWeight.w400,
481 color: Theme.of(context).colorScheme.onSurface,
482 ),
483 ),
484 ),
485 if (enabled)
486 Icon(
487 Icons.keyboard_arrow_down,
488 size: 24,
489 color: Theme.of(context).colorScheme.primary,
490 ),
491 ],
492 ),
493 ),
494 ),
495 );
496 }
497 }