dev
dart 1,147 lines 43.8 KB
Raw
1 import 'dart:async';
2
3 import 'package:cake_wallet/core/open_crypto_pay/open_cryptopay_service.dart';
4 import 'package:cake_wallet/entities/balance_display_mode.dart';
5 import 'package:cake_wallet/entities/priority_for_wallet_type.dart';
6 import 'package:cake_wallet/src/screens/receive/widgets/currency_input_field.dart';
7 import 'package:cake_wallet/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart';
8 import 'package:cake_wallet/src/widgets/bottom_sheet/wallet_switcher_bottom_sheet.dart';
9 import 'package:cake_wallet/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart';
10 import 'package:cake_wallet/src/widgets/bottom_sheet/token_selection_bottom_sheet.dart';
11 import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
12 import 'package:cake_wallet/src/widgets/picker.dart';
13 import 'package:cake_wallet/src/widgets/standard_checkbox.dart';
14 import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
15
16 import 'package:cake_wallet/themes/core/material_base_theme.dart';
17 import 'package:cake_wallet/utils/payment_request.dart';
18 import 'package:cake_wallet/utils/responsive_layout_util.dart';
19 import 'package:cake_wallet/view_model/payment/payment_view_model.dart';
20 import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart';
21 import 'package:cake_wallet/exchange/trade.dart';
22 import 'package:cw_core/crypto_currency.dart';
23 import 'package:cw_core/currency.dart';
24 import 'package:cake_wallet/routes.dart';
25 import 'package:cake_wallet/view_model/send/output.dart';
26 import 'package:cw_core/transaction_priority.dart';
27 import 'package:cw_core/unspent_coin_type.dart';
28 import 'package:cw_core/utils/print_verbose.dart';
29 import 'package:cw_core/wallet_info.dart';
30 import 'package:cw_core/wallet_type.dart';
31 import 'package:flutter/material.dart';
32 import 'package:flutter_mobx/flutter_mobx.dart';
33 import 'package:mobx/mobx.dart';
34 import 'package:cake_wallet/view_model/send/send_view_model.dart';
35 import 'package:cake_wallet/utils/show_pop_up.dart';
36 import 'package:cake_wallet/src/widgets/address_text_field.dart';
37 import 'package:cake_wallet/generated/i18n.dart';
38 import 'package:cake_wallet/src/widgets/base_text_form_field.dart';
39 import 'package:cake_wallet/di.dart';
40 import 'package:cake_wallet/evm/evm.dart';
41 import 'package:cake_wallet/reactions/wallet_connect.dart';
42 import 'package:cake_wallet/store/app_store.dart';
43
44 class SendCard extends StatefulWidget {
45 SendCard({
46 Key? key,
47 required this.output,
48 required this.sendViewModel,
49 required this.paymentViewModel,
50 required this.walletSwitcherViewModel,
51 required this.currentTheme,
52 this.initialPaymentRequest,
53 this.cryptoAmountFocus,
54 this.fiatAmountFocus,
55 }) : super(key: key);
56
57 final Output output;
58 final SendViewModel sendViewModel;
59 final PaymentViewModel paymentViewModel;
60 final WalletSwitcherViewModel walletSwitcherViewModel;
61 final PaymentRequest? initialPaymentRequest;
62 final FocusNode? cryptoAmountFocus;
63 final FocusNode? fiatAmountFocus;
64 final MaterialThemeBase currentTheme;
65
66 @override
67 SendCardState createState() => SendCardState(
68 output: output,
69 sendViewModel: sendViewModel,
70 paymentViewModel: paymentViewModel,
71 walletSwitcherViewModel: walletSwitcherViewModel,
72 initialPaymentRequest: initialPaymentRequest,
73 currentTheme: currentTheme,
74 // cryptoAmountFocus: cryptoAmountFocus ?? FocusNode(),
75 // fiatAmountFocus: fiatAmountFocus ?? FocusNode(),
76 // cryptoAmountFocus: FocusNode(),
77 // fiatAmountFocus: FocusNode(),
78 );
79 }
80
81 class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<SendCard> {
82 SendCardState({
83 required this.output,
84 required this.sendViewModel,
85 required this.paymentViewModel,
86 required this.walletSwitcherViewModel,
87 this.initialPaymentRequest,
88 required this.currentTheme,
89 }) : addressController = TextEditingController(),
90 cryptoAmountController = TextEditingController(),
91 fiatAmountController = TextEditingController(),
92 noteController = TextEditingController(),
93 extractedAddressController = TextEditingController(),
94 addressFocusNode = FocusNode();
95
96 static const prefixIconWidth = 34.0;
97 static const prefixIconHeight = 34.0;
98
99 final MaterialThemeBase currentTheme;
100 final Output output;
101 final SendViewModel sendViewModel;
102 final PaymentViewModel paymentViewModel;
103 final WalletSwitcherViewModel walletSwitcherViewModel;
104 final PaymentRequest? initialPaymentRequest;
105
106 final TextEditingController addressController;
107 final TextEditingController cryptoAmountController;
108 final TextEditingController fiatAmountController;
109 final TextEditingController noteController;
110 final TextEditingController extractedAddressController;
111 final FocusNode addressFocusNode;
112
113 bool _effectsInstalled = false;
114 BuildContext? loadingBottomSheetContext;
115 bool _justHandledPasteButton = false;
116 String _lastHandledAddress = '';
117
118 @override
119 void initState() {
120 super.initState();
121 WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
122 sendViewModel.updateSendingBalance();
123 });
124
125 /// if the current wallet doesn't match the one in the qr code
126 if (initialPaymentRequest != null &&
127 sendViewModel.walletCurrencyName != initialPaymentRequest!.scheme.toLowerCase()) {
128 WidgetsBinding.instance.addPostFrameCallback(
129 (timeStamp) {
130 if (mounted) {
131 final prefix =
132 initialPaymentRequest!.scheme.isNotEmpty ? "${initialPaymentRequest!.scheme}:" : "";
133 final amount = initialPaymentRequest!.amount.isNotEmpty
134 ? "?amount=${initialPaymentRequest!.amount}"
135 : "";
136 final uri = prefix + initialPaymentRequest!.address + amount;
137 _handlePaymentFlow(uri, initialPaymentRequest!);
138 }
139 },
140 );
141 }
142 }
143
144 @override
145 void dispose() {
146 addressController.dispose();
147 cryptoAmountController.dispose();
148 fiatAmountController.dispose();
149 noteController.dispose();
150 extractedAddressController.dispose();
151 addressFocusNode.dispose();
152 super.dispose();
153 }
154
155 Future<void> _handlePaymentFlow(String uri, PaymentRequest paymentRequest) async {
156 if (uri.contains('@') || paymentRequest.address.contains('@')) return;
157
158 if (OpenCryptoPayService.isOpenCryptoPayQR(uri)) {
159 sendViewModel.createOpenCryptoPayTransaction(uri);
160 return;
161 }
162
163 try {
164 final result = await paymentViewModel.processAddress(uri);
165
166 if (paymentRequest.contractAddress != null) {
167 await sendViewModel.fetchTokenForContractAddress(paymentRequest.contractAddress!);
168 }
169
170 switch (result.type) {
171 case PaymentFlowType.singleWallet:
172 case PaymentFlowType.multipleWallets:
173 case PaymentFlowType.noWallets:
174 await _showPaymentConfirmation(
175 paymentViewModel,
176 walletSwitcherViewModel,
177 paymentRequest,
178 result,
179 );
180 break;
181 case PaymentFlowType.evmNetworkSelection:
182 await _showTokenSelectionFlow(
183 paymentViewModel,
184 walletSwitcherViewModel,
185 paymentRequest,
186 fixedNetwork: result.walletType,
187 );
188 break;
189 case PaymentFlowType.solanaTokenSelection:
190 await _showTokenSelectionFlow(
191 paymentViewModel,
192 walletSwitcherViewModel,
193 paymentRequest,
194 fixedNetwork: WalletType.solana,
195 );
196 break;
197 case PaymentFlowType.tronTokenSelection:
198 await _showTokenSelectionFlow(
199 paymentViewModel,
200 walletSwitcherViewModel,
201 paymentRequest,
202 fixedNetwork: WalletType.tron,
203 );
204
205 break;
206 case PaymentFlowType.currentWalletCompatible:
207 case PaymentFlowType.error:
208 case PaymentFlowType.incompatible:
209 _applyPaymentRequest(paymentRequest);
210 break;
211 }
212 } catch (e) {
213 printV('Payment flow error: $e');
214 _applyPaymentRequest(paymentRequest);
215 }
216 }
217
218 Future<void> _showPaymentConfirmation(
219 PaymentViewModel paymentViewModel,
220 WalletSwitcherViewModel walletSwitcherViewModel,
221 PaymentRequest paymentRequest,
222 PaymentFlowResult result,
223 ) async {
224 if (!context.mounted) {
225 return;
226 }
227
228 await showModalBottomSheet<void>(
229 context: context,
230 isDismissible: true,
231 isScrollControlled: true,
232 builder: (BuildContext context) {
233 return PaymentConfirmationBottomSheet(
234 paymentFlowResult: result,
235 paymentViewModel: paymentViewModel,
236 walletSwitcherViewModel: walletSwitcherViewModel,
237 paymentRequest: paymentRequest,
238 onSelectWallet: () => _handleSelectWallet(
239 paymentViewModel,
240 walletSwitcherViewModel,
241 paymentRequest,
242 result,
243 ),
244 onChangeWallet: () => _handleChangeWallet(
245 paymentViewModel,
246 walletSwitcherViewModel,
247 paymentRequest,
248 result,
249 ),
250 onSwap: (bottomSheetContext) =>
251 _handleSwapFlow(paymentViewModel, result, bottomSheetContext),
252 onSwitchNetwork: () => _handleSwitchNetwork(
253 paymentViewModel,
254 walletSwitcherViewModel,
255 paymentRequest,
256 result,
257 ),
258 );
259 },
260 );
261 }
262
263 Future<void> _showTokenSelectionFlow(
264 PaymentViewModel paymentViewModel,
265 WalletSwitcherViewModel walletSwitcherViewModel,
266 PaymentRequest paymentRequest, {
267 WalletType? fixedNetwork,
268 }) async {
269 if (!context.mounted) {
270 return;
271 }
272
273 await showModalBottomSheet<void>(
274 context: context,
275 isDismissible: true,
276 isScrollControlled: true,
277 builder: (BuildContext context) {
278 return TokenSelectionBottomSheet(
279 paymentViewModel: paymentViewModel,
280 paymentRequest: paymentRequest,
281 fixedNetwork: fixedNetwork,
282 onNext: (PaymentFlowResult newResult) {
283 final canCheckCompatibility = evm != null &&
284 isEVMCompatibleChain(sendViewModel.wallet.type) &&
285 newResult.chainId != null;
286
287 if (canCheckCompatibility) {
288 final selectedChainId = newResult.chainId!;
289 final isCompatible = selectedChainId == evm!.getSelectedChainId(sendViewModel.wallet);
290
291 if (isCompatible) {
292 sendViewModel.setSelectedCryptoCurrency(
293 newResult.addressDetectionResult!.detectedCurrency!.title,
294 );
295 _applyPaymentRequest(paymentRequest);
296 return;
297 }
298 }
299
300 _showPaymentConfirmation(
301 paymentViewModel,
302 walletSwitcherViewModel,
303 paymentRequest,
304 newResult,
305 );
306 },
307 );
308 },
309 );
310 }
311
312 Future<void> _handleSelectWallet(
313 PaymentViewModel paymentViewModel,
314 WalletSwitcherViewModel walletSwitcherViewModel,
315 PaymentRequest paymentRequest,
316 PaymentFlowResult result,
317 ) async {
318 Navigator.of(context).pop();
319
320 await showModalBottomSheet<WalletInfo>(
321 context: context,
322 isDismissible: true,
323 isScrollControlled: true,
324 builder: (BuildContext dialogContext) {
325 return WalletSwitcherBottomSheet(
326 viewModel: walletSwitcherViewModel,
327 filterWalletType: paymentViewModel.detectedWalletType,
328 );
329 },
330 );
331
332 final success = await walletSwitcherViewModel.switchToSelectedWallet();
333
334 if (success) {
335 if (isEVMCompatibleChain(sendViewModel.wallet.type) && result.chainId != null) {
336 final appStore = getIt.get<AppStore>();
337 final node = appStore.settingsStore.getCurrentNode(
338 sendViewModel.wallet.type,
339 chainId: result.chainId,
340 );
341 await evm!.selectChain(sendViewModel.wallet, result.chainId!, node: node);
342 }
343
344 await sendViewModel.wallet.updateBalance();
345
346 final detectedCurrency = result.addressDetectionResult!.detectedCurrency;
347 if (detectedCurrency != null) {
348 sendViewModel.setSelectedCryptoCurrency(detectedCurrency.title);
349 }
350
351 _applyPaymentRequest(paymentRequest);
352 }
353 }
354
355 Future<void> _handleChangeWallet(
356 PaymentViewModel paymentViewModel,
357 WalletSwitcherViewModel walletSwitcherViewModel,
358 PaymentRequest paymentRequest,
359 PaymentFlowResult result,
360 ) async {
361 if (mounted && Navigator.of(context).canPop()) {
362 Navigator.of(context).pop();
363 }
364
365 if (result.type == PaymentFlowType.singleWallet && result.wallet != null) {
366 walletSwitcherViewModel.selectWallet(result.wallet!);
367 final success = await walletSwitcherViewModel.switchToSelectedWallet();
368 if (success) {
369 WidgetsBinding.instance.addPostFrameCallback((_) {
370 if (mounted) {
371 showModalBottomSheet<void>(
372 context: context,
373 isDismissible: false,
374 builder: (BuildContext context) {
375 loadingBottomSheetContext = context;
376 return LoadingBottomSheet(
377 titleText: S.of(context).loading_your_wallet,
378 );
379 },
380 );
381 }
382 });
383
384 // If EVM wallet and chainId is specified, switch to that chain
385 if (isEVMCompatibleChain(sendViewModel.wallet.type) && result.chainId != null) {
386 final appStore = getIt.get<AppStore>();
387 final node = appStore.settingsStore.getCurrentNode(
388 sendViewModel.wallet.type,
389 chainId: result.chainId,
390 );
391 await evm!.selectChain(sendViewModel.wallet, result.chainId!, node: node);
392 }
393
394 await Future.delayed(const Duration(seconds: 2));
395 if (loadingBottomSheetContext != null &&
396 loadingBottomSheetContext!.mounted &&
397 Navigator.canPop(loadingBottomSheetContext!)) {
398 Navigator.of(loadingBottomSheetContext!).pop();
399 }
400
401 await sendViewModel.wallet.updateBalance();
402 sendViewModel
403 .setSelectedCryptoCurrency(result.addressDetectionResult!.detectedCurrency!.title);
404 _applyPaymentRequest(paymentRequest);
405 }
406 } else if (result.wallets.isNotEmpty && result.wallets.length == 1) {
407 walletSwitcherViewModel.selectWallet(result.wallets.first);
408 final success = await walletSwitcherViewModel.switchToSelectedWallet();
409 if (success) {
410 WidgetsBinding.instance.addPostFrameCallback((_) {
411 if (context.mounted) {
412 showModalBottomSheet<void>(
413 context: context,
414 isDismissible: false,
415 builder: (BuildContext context) {
416 loadingBottomSheetContext = context;
417 return LoadingBottomSheet(
418 titleText: S.of(context).loading_your_wallet,
419 );
420 },
421 );
422 }
423 });
424
425 // If EVM wallet and chainId is specified, switch to that chain
426 if (isEVMCompatibleChain(sendViewModel.wallet.type) && result.chainId != null) {
427 final appStore = getIt.get<AppStore>();
428 final node = appStore.settingsStore.getCurrentNode(
429 sendViewModel.wallet.type,
430 chainId: result.chainId,
431 );
432 await evm!.selectChain(sendViewModel.wallet, result.chainId!, node: node);
433 }
434
435 await Future.delayed(const Duration(seconds: 2));
436 if (loadingBottomSheetContext != null && loadingBottomSheetContext!.mounted) {
437 Navigator.of(loadingBottomSheetContext!).pop();
438 }
439
440 await sendViewModel.wallet.updateBalance();
441 sendViewModel
442 .setSelectedCryptoCurrency(result.addressDetectionResult!.detectedCurrency!.title);
443 _applyPaymentRequest(paymentRequest);
444 }
445 }
446 }
447
448 Future<void> _handleSwitchNetwork(
449 PaymentViewModel paymentViewModel,
450 WalletSwitcherViewModel walletSwitcherViewModel,
451 PaymentRequest paymentRequest,
452 PaymentFlowResult result,
453 ) async {
454 if (result.type != PaymentFlowType.evmNetworkSelection || result.wallet == null) return;
455
456 if (context.mounted && Navigator.of(context).canPop()) {
457 Navigator.of(context).pop();
458 }
459
460 try {
461 WidgetsBinding.instance.addPostFrameCallback((_) {
462 if (context.mounted) {
463 showModalBottomSheet<void>(
464 context: context,
465 isDismissible: false,
466 builder: (BuildContext context) {
467 loadingBottomSheetContext = context;
468 return LoadingBottomSheet(
469 titleText: S.of(context).loading_your_wallet,
470 );
471 },
472 );
473 }
474 });
475
476 await paymentViewModel.selectChain();
477
478 await Future.delayed(const Duration(seconds: 2));
479 if (loadingBottomSheetContext != null && loadingBottomSheetContext!.mounted) {
480 Navigator.of(loadingBottomSheetContext!).pop();
481 }
482
483 await sendViewModel.wallet.updateBalance();
484 final detectedCurrency = result.addressDetectionResult?.detectedCurrency;
485 if (detectedCurrency != null) {
486 sendViewModel.setSelectedCryptoCurrency(detectedCurrency.title);
487 }
488 _applyPaymentRequest(paymentRequest);
489 } catch (e) {
490 if (loadingBottomSheetContext != null && loadingBottomSheetContext!.mounted) {
491 Navigator.of(loadingBottomSheetContext!).pop();
492 }
493 printV('Switch network error: $e');
494 }
495 }
496
497 /// Apply payment request to current form
498 void _applyPaymentRequest(PaymentRequest paymentRequest) {
499 if (sendViewModel.usePayjoin) {
500 sendViewModel.payjoinUri = paymentRequest.pjUri;
501 }
502 addressController.text = paymentRequest.address;
503 if (paymentRequest.amount.isNotEmpty) {
504 cryptoAmountController.text = paymentRequest.amount;
505 }
506 noteController.text = paymentRequest.note;
507 }
508
509 Future<void> _handleSwapFlow(
510 PaymentViewModel paymentViewModel,
511 PaymentFlowResult result,
512 BuildContext bottomSheetContext,
513 ) async {
514 Navigator.of(bottomSheetContext).pop();
515
516 await Future.delayed(const Duration(milliseconds: 100));
517
518 if (!mounted) return;
519
520 final bottomSheet = getIt.get<SwapConfirmationBottomSheet>(param1: result);
521 await showModalBottomSheet<Trade?>(
522 context: context,
523 isDismissible: true,
524 isScrollControlled: true,
525 builder: (BuildContext context) => bottomSheet,
526 );
527 }
528
529 @override
530 Widget build(BuildContext context) {
531 super.build(context);
532 _setEffects(context);
533
534 // return Stack(
535 // children: [
536 // return KeyboardActions(
537 // config: KeyboardActionsConfig(
538 // keyboardActionsPlatform: KeyboardActionsPlatform.IOS,
539 // keyboardBarColor: Theme.of(context).extension<KeyboardTheme>()!.keyboardBarColor,
540 // nextFocus: false,
541 // actions: [
542 // KeyboardActionsItem(
543 // focusNode: cryptoAmountFocus,
544 // toolbarButtons: [(_) => KeyboardDoneButton()],
545 // ),
546 // KeyboardActionsItem(
547 // focusNode: fiatAmountFocus,
548 // toolbarButtons: [(_) => KeyboardDoneButton()],
549 // )
550 // ],
551 // ),
552 // // child: Container(
553 // // height: 0,
554 // // color: Colors.transparent,
555 // // ), child:
556 // child: SizedBox(
557 // height: 100,
558 // width: 100,
559 // child: Text('Send Card'),
560 // ),
561 // );
562 return Container(
563 decoration: responsiveLayoutUtil.shouldRenderMobileUI
564 ? BoxDecoration(
565 borderRadius: BorderRadius.only(
566 bottomLeft: Radius.circular(24),
567 bottomRight: Radius.circular(24),
568 ),
569 color: Theme.of(context).colorScheme.surfaceContainer,
570 )
571 : null,
572 child: Padding(
573 padding: EdgeInsets.fromLTRB(
574 24,
575 responsiveLayoutUtil.shouldRenderMobileUI ? 110 : 55,
576 24,
577 responsiveLayoutUtil.shouldRenderMobileUI ? 32 : 0,
578 ),
579 child: Observer(
580 builder: (_) => Column(
581 mainAxisSize: MainAxisSize.min,
582 children: <Widget>[
583 Observer(builder: (_) {
584 final validator = output.isParsedAddress
585 ? sendViewModel.textValidator
586 : sendViewModel.addressValidator;
587
588 return AddressTextField(
589 contentPadding: EdgeInsets.symmetric(vertical: 8),
590 hasUnderlineBorder: true,
591 fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
592 addressKey: ValueKey('send_page_address_textfield_key'),
593 focusNode: addressFocusNode,
594 controller: addressController,
595 onURIScanned: (uri) async {
596 output.resetParsedAddress();
597
598 // Process the payment through the new flow
599 await _handlePaymentFlow(
600 uri.toString(),
601 PaymentRequest.fromUri(uri),
602 );
603 },
604 options: [
605 AddressTextFieldOption.paste,
606 AddressTextFieldOption.qrCode,
607 AddressTextFieldOption.addressBook
608 ],
609 textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
610 fontSize: 16,
611 fontWeight: FontWeight.w500,
612 ),
613 hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
614 fontSize: 16,
615 fontWeight: FontWeight.w500,
616 color: Theme.of(context).colorScheme.onSurfaceVariant,
617 ),
618 onPushPasteButton: (context) async {
619 _justHandledPasteButton = true;
620 try {
621 output.resetParsedAddress();
622
623 final address =
624 output.isParsedAddress ? output.extractedAddress : output.address;
625
626 await _handlePaymentFlow(
627 address,
628 PaymentRequest(
629 address,
630 cryptoAmountController.text,
631 noteController.text,
632 "",
633 null,
634 ),
635 );
636 } finally {
637 _justHandledPasteButton = false;
638 }
639 },
640 onPushAddressBookButton: (context) async {
641 output.resetParsedAddress();
642 },
643 onSelectedContact: (contact) {},
644 validator: validator,
645 selectedCurrency: sendViewModel.selectedCryptoCurrency,
646 );
647 }),
648 if (output.isParsedAddress)
649 Padding(
650 padding: const EdgeInsets.only(top: 20),
651 child: BaseTextFormField(
652 fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
653 controller: extractedAddressController,
654 readOnly: true,
655 enableInteractiveSelection: false,
656 textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
657 fontSize: 16,
658 fontWeight: FontWeight.w500,
659 color: Theme.of(context).colorScheme.onSurface,
660 ),
661 validator: sendViewModel.addressValidator,
662 ),
663 ),
664 CurrencyAmountTextField(
665 borderWidth: 0.0,
666 hasUnderlineBorder: true,
667 fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
668 currencyPickerButtonKey: ValueKey('send_page_currency_picker_button_key'),
669 amountTextfieldKey: ValueKey('send_page_amount_textfield_key'),
670 sendAllButtonKey: ValueKey('send_page_send_all_button_key'),
671 currencyAmountTextFieldWidgetKey:
672 ValueKey('send_page_crypto_currency_amount_textfield_widget_key'),
673 selectedCurrency:
674 output.useSatoshi ? "SATS" : sendViewModel.selectedCryptoCurrency.title,
675 selectedCurrencyDecimals:
676 output.useSatoshi ? 0 : sendViewModel.selectedCryptoCurrency.decimals,
677 amountFocusNode: widget.cryptoAmountFocus,
678 amountController: cryptoAmountController,
679 isAmountEditable: true,
680 onTapPicker: () => _presentPicker(context),
681 isPickerEnable: sendViewModel.hasMultipleTokens,
682 tag: sendViewModel.selectedCryptoCurrency.tag,
683 allAmountButton:
684 !sendViewModel.isBatchSending && sendViewModel.shouldDisplaySendALL,
685 currencyValueValidator: output.sendAll
686 ? sendViewModel.allAmountValidator
687 : sendViewModel.amountValidator(output),
688 allAmountCallback: () async =>
689 output.setSendAll(await sendViewModel.sendingBalance),
690 ),
691 Divider(height: 1, color: Theme.of(context).colorScheme.outlineVariant),
692 Observer(
693 builder: (_) {
694 // force rebuild on mobx
695 final _ = sendViewModel.coinTypeToSpendFrom;
696 return Padding(
697 padding: EdgeInsets.only(top: 10),
698 child: Row(
699 mainAxisSize: MainAxisSize.max,
700 mainAxisAlignment: MainAxisAlignment.spaceBetween,
701 children: <Widget>[
702 Expanded(
703 child: Text(
704 S.of(context).available_balance + ':',
705 style: Theme.of(context).textTheme.bodySmall!.copyWith(
706 fontWeight: FontWeight.w600,
707 color: Theme.of(context).colorScheme.onSurfaceVariant,
708 ),
709 ),
710 ),
711 FutureBuilder<String>(
712 future: sendViewModel.sendingBalance,
713 builder: (context, snapshot) {
714 return GestureDetector(
715 onTap: () {
716 sendViewModel.balanceViewModel.switchBalanceValue();
717 },
718 child: Observer(builder: (_) {
719 final hidden = sendViewModel.balanceViewModel.displayMode ==
720 BalanceDisplayMode.hiddenBalance;
721 return Text(
722 hidden
723 ? S.of(context).show_balance_send_page
724 : (snapshot.data ?? sendViewModel.balance),
725 // default to balance while loading
726 style: Theme.of(context).textTheme.bodySmall!.copyWith(
727 fontWeight: FontWeight.w600,
728 color: hidden
729 ? Theme.of(context).colorScheme.primary
730 : Theme.of(context).colorScheme.onSurfaceVariant,
731 ),
732 );
733 }),
734 );
735 },
736 )
737 ],
738 ),
739 );
740 },
741 ),
742 if (!sendViewModel.isFiatDisabled)
743 CurrencyAmountTextField(
744 borderWidth: 0.0,
745 hasUnderlineBorder: true,
746 fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
747 amountTextfieldKey: ValueKey('send_page_fiat_amount_textfield_key'),
748 currencyAmountTextFieldWidgetKey:
749 ValueKey('send_page_fiat_currency_amount_textfield_widget_key'),
750 selectedCurrency: sendViewModel.fiat.title,
751 selectedCurrencyDecimals: sendViewModel.fiat.decimals,
752 amountFocusNode: widget.fiatAmountFocus,
753 amountController: fiatAmountController,
754 hintText: '0.00',
755 isAmountEditable: true,
756 allAmountButton: false,
757 ),
758 Divider(height: 1, color: Theme.of(context).colorScheme.outlineVariant),
759 Padding(
760 padding: EdgeInsets.only(top: 20),
761 child: BaseTextFormField(
762 hasUnderlineBorder: true,
763 contentPadding: EdgeInsets.symmetric(vertical: 8),
764 fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
765 key: ValueKey('send_page_note_textfield_key'),
766 controller: noteController,
767 keyboardType: TextInputType.multiline,
768 maxLines: null,
769 textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
770 fontWeight: FontWeight.w500,
771 ),
772 hintText: S.of(context).note_optional,
773 placeholderTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
774 fontWeight: FontWeight.w500,
775 color: Theme.of(context).colorScheme.onSurfaceVariant,
776 ),
777 ),
778 ),
779 if (sendViewModel.hasFees)
780 Observer(
781 builder: (_) => GestureDetector(
782 key: ValueKey('send_page_select_fee_priority_button_key'),
783 onTap: sendViewModel.feesViewModel.hasFeesPriority
784 ? () => pickTransactionPriority(context, output)
785 : () {},
786 child: Container(
787 padding: EdgeInsets.only(top: 24),
788 child: Row(
789 mainAxisAlignment: MainAxisAlignment.spaceBetween,
790 crossAxisAlignment: CrossAxisAlignment.start,
791 children: <Widget>[
792 Text(
793 S.of(context).send_estimated_fee,
794 style: Theme.of(context).textTheme.bodySmall!.copyWith(
795 fontWeight: FontWeight.w700,
796 ),
797 ),
798 Container(
799 child: Row(
800 crossAxisAlignment: CrossAxisAlignment.start,
801 children: <Widget>[
802 Column(
803 mainAxisAlignment: MainAxisAlignment.start,
804 crossAxisAlignment: CrossAxisAlignment.end,
805 children: [
806 Text(
807 '${output.estimatedFee} ${sendViewModel.currencySymbol}',
808 style: Theme.of(context).textTheme.bodySmall!.copyWith(
809 fontWeight: FontWeight.w600,
810 ),
811 ),
812 Padding(
813 padding: EdgeInsets.only(top: 5),
814 child: sendViewModel.isFiatDisabled
815 ? const SizedBox(height: 14)
816 : Text(
817 '${output.estimatedFeeFiatAmount} ${sendViewModel.fiat.title}',
818 style:
819 Theme.of(context).textTheme.bodySmall!.copyWith(
820 fontWeight: FontWeight.w600,
821 color: Theme.of(context)
822 .colorScheme
823 .onSurfaceVariant,
824 ),
825 ),
826 ),
827 ],
828 ),
829 Padding(
830 padding: EdgeInsets.only(top: 2, left: 5),
831 child: Icon(
832 Icons.arrow_forward_ios,
833 size: 12,
834 color: Theme.of(context).colorScheme.onSurface,
835 ),
836 )
837 ],
838 ),
839 )
840 ],
841 ),
842 ),
843 ),
844 ),
845 if (sendViewModel.hasCoinControl)
846 Padding(
847 padding: EdgeInsets.only(top: 6),
848 child: GestureDetector(
849 key: ValueKey('send_page_unspent_coin_button_key'),
850 onTap: () async {
851 await Navigator.of(context).pushNamed(
852 Routes.unspentCoinsList,
853 arguments: widget.sendViewModel.coinTypeToSpendFrom,
854 );
855 if (mounted) {
856 // we just got back from the unspent coins list screen, so we need to recompute the sending balance:
857 sendViewModel.updateSendingBalance();
858 }
859 },
860 child: Container(
861 color: Colors.transparent,
862 child: Row(
863 mainAxisAlignment: MainAxisAlignment.spaceBetween,
864 children: [
865 Text(
866 S.of(context).coin_control,
867 style: Theme.of(context).textTheme.bodySmall!.copyWith(
868 fontWeight: FontWeight.w700,
869 ),
870 ),
871 Icon(
872 Icons.arrow_forward_ios,
873 size: 12,
874 color: Theme.of(context).colorScheme.onSurface,
875 ),
876 ],
877 ),
878 ),
879 ),
880 ),
881 if (sendViewModel.isMwebAvailable)
882 Observer(
883 builder: (_) => Padding(
884 padding: EdgeInsets.only(top: 14),
885 child: GestureDetector(
886 key: ValueKey('send_page_unspent_coin_button_key'),
887 onTap: () {
888 bool value =
889 widget.sendViewModel.coinTypeToSpendFrom == UnspentCoinType.any;
890 sendViewModel.setAllowMwebCoins(!value);
891 },
892 child: Container(
893 color: Colors.transparent,
894 child: Row(
895 mainAxisAlignment: MainAxisAlignment.spaceBetween,
896 children: [
897 StandardCheckbox(
898 caption: S.of(context).litecoin_mweb_allow_coins,
899 captionColor: Theme.of(context).colorScheme.onSurfaceVariant,
900 borderColor: Theme.of(context).colorScheme.primary,
901 iconColor: Theme.of(context).colorScheme.primary,
902 value:
903 widget.sendViewModel.coinTypeToSpendFrom == UnspentCoinType.any,
904 onChanged: (bool? value) {
905 sendViewModel.setAllowMwebCoins(value ?? false);
906 },
907 ),
908 ],
909 ),
910 ),
911 ),
912 ),
913 ),
914 ],
915 ),
916 ),
917 ),
918 );
919 }
920
921 void _setEffects(BuildContext context) {
922 if (_effectsInstalled) {
923 return;
924 }
925
926 if (output.address.isNotEmpty) {
927 addressController.text = output.address;
928 }
929 if (output.cryptoAmount.isNotEmpty) {
930 cryptoAmountController.text = output.cryptoAmount;
931 }
932 fiatAmountController.text = output.fiatAmount;
933 noteController.text = output.note;
934 extractedAddressController.text = output.extractedAddress;
935
936 cryptoAmountController.addListener(() {
937 final amount = cryptoAmountController.text;
938
939 if (output.sendAll && amount != S.current.all) {
940 output.sendAll = false;
941 }
942
943 if (S.current.all.contains(amount)) return;
944
945 final cAmount = sendViewModel.amountParsingProxy
946 .getDisplayCryptoAmount(output.cryptoAmount, sendViewModel.selectedCryptoCurrency);
947 if (amount != cAmount) {
948 final newAmount = sendViewModel.amountParsingProxy
949 .getCanonicalCryptoAmount(amount, sendViewModel.selectedCryptoCurrency);
950 output.setCryptoAmount(newAmount);
951 }
952 });
953
954 fiatAmountController.addListener(() {
955 final amount = fiatAmountController.text;
956
957 if (amount != output.fiatAmount) {
958 output.sendAll = false;
959 output.setFiatAmount(amount);
960 }
961 });
962
963 noteController.addListener(() {
964 final note = noteController.text;
965
966 if (note != output.note) {
967 output.note = note;
968 }
969 });
970
971 reaction((_) => output.sendAll, (bool all) {
972 if (all) cryptoAmountController.text = S.current.all;
973 });
974
975 reaction((_) => sendViewModel.selectedCryptoCurrency, (Currency currency) async {
976 if (output.sendAll) {
977 output.setSendAll(await sendViewModel.sendingBalance);
978 }
979
980 output.setCryptoAmount(sendViewModel.amountParsingProxy.getCanonicalCryptoAmount(
981 cryptoAmountController.text, sendViewModel.selectedCryptoCurrency));
982 });
983
984 reaction((_) => output.fiatAmount, (String amount) {
985 if (amount != fiatAmountController.text) {
986 fiatAmountController.text = amount;
987 }
988 });
989
990 reaction((_) => output.cryptoAmount, (String amount) {
991 if (output.sendAll && amount != S.current.all) {
992 output.sendAll = false;
993 }
994
995 final cryptoAmount = sendViewModel.amountParsingProxy.getCanonicalCryptoAmount(
996 cryptoAmountController.text, sendViewModel.selectedCryptoCurrency);
997 if (amount != cryptoAmount) {
998 cryptoAmountController.text = sendViewModel.amountParsingProxy
999 .getDisplayCryptoAmount(amount, sendViewModel.selectedCryptoCurrency);
1000 }
1001 });
1002
1003 reaction((_) => output.address, (String address) {
1004 if (address != addressController.text) {
1005 addressController.text = address;
1006 }
1007 });
1008
1009 addressController.addListener(() {
1010 final address = addressController.text;
1011
1012 if (output.address != address) {
1013 output.resetParsedAddress();
1014 output.address = address;
1015
1016 if (SendViewModelBase.isNonZeroAmountLightningInvoice(address)) {
1017 sendViewModel.createTransaction();
1018 }
1019 }
1020 });
1021
1022 reaction((_) => output.note, (String note) {
1023 if (note != noteController.text) {
1024 noteController.text = note;
1025 }
1026 });
1027
1028 addressFocusNode.addListener(() async {
1029 if (!addressFocusNode.hasFocus && addressController.text.isNotEmpty) {
1030 final current = addressController.text.trim();
1031 if (current.isEmpty) return;
1032 if (_justHandledPasteButton || _lastHandledAddress == current) return;
1033
1034 // If it's a URI with params, go through URI flow
1035 if (current.contains('=')) {
1036 try {
1037 final uri = Uri.parse(current);
1038 _lastHandledAddress = current;
1039 await _handlePaymentFlow(
1040 uri.toString(),
1041 PaymentRequest.fromUri(uri),
1042 );
1043 return;
1044 } catch (_) {
1045 // fall through to plain address
1046 }
1047 }
1048
1049 final parsedAddress = output.isParsedAddress ? output.extractedAddress : output.address;
1050
1051 _lastHandledAddress = current;
1052 await _handlePaymentFlow(
1053 parsedAddress,
1054 PaymentRequest(
1055 parsedAddress,
1056 cryptoAmountController.text,
1057 noteController.text,
1058 "",
1059 null,
1060 ),
1061 );
1062 }
1063 });
1064
1065 reaction((_) => output.extractedAddress, (String extractedAddress) {
1066 extractedAddressController.text = extractedAddress;
1067 });
1068
1069 if (initialPaymentRequest != null &&
1070 sendViewModel.walletCurrencyName == initialPaymentRequest!.scheme.toLowerCase()) {
1071 addressController.text = initialPaymentRequest!.address;
1072 cryptoAmountController.text = initialPaymentRequest!.amount;
1073 noteController.text = initialPaymentRequest!.note;
1074 }
1075
1076 reaction((_) => sendViewModel.isReadyForSend, (bool isReadyForSend) {
1077 if (isReadyForSend) {
1078 sendViewModel.updateSendingBalance();
1079 }
1080 });
1081
1082 _effectsInstalled = true;
1083 }
1084
1085 Future<void> pickTransactionPriority(BuildContext context, Output output) async {
1086 final items = priorityForWalletType(sendViewModel.walletType);
1087 final selectedItem = items.indexOf(sendViewModel.feesViewModel.transactionPriority);
1088 final customItemIndex = sendViewModel.feesViewModel.getCustomPriorityIndex(items);
1089 final isBitcoinWallet = sendViewModel.walletType == WalletType.bitcoin;
1090 final maxCustomFeeRate = sendViewModel.feesViewModel.maxCustomFeeRate?.toDouble();
1091 double? customFeeRate =
1092 isBitcoinWallet ? sendViewModel.feesViewModel.customBitcoinFeeRate.toDouble() : null;
1093
1094 FocusManager.instance.primaryFocus?.unfocus();
1095
1096 await showPopUp<void>(
1097 context: context,
1098 builder: (BuildContext context) {
1099 int selectedIdx = selectedItem;
1100 return StatefulBuilder(
1101 builder: (BuildContext context, StateSetter setState) {
1102 return Picker(
1103 items: items,
1104 displayItem: (TransactionPriority priority) =>
1105 sendViewModel.feesViewModel.displayFeeRate(priority, customFeeRate?.round()),
1106 selectedAtIndex: selectedIdx,
1107 customItemIndex: customItemIndex,
1108 maxValue: maxCustomFeeRate,
1109 title: S.of(context).please_select,
1110 headerEnabled: !isBitcoinWallet,
1111 closeOnItemSelected: !isBitcoinWallet,
1112 mainAxisAlignment: MainAxisAlignment.center,
1113 sliderValue: customFeeRate,
1114 onSliderChanged: (double newValue) => setState(() => customFeeRate = newValue),
1115 onItemSelected: (TransactionPriority priority) async {
1116 sendViewModel.feesViewModel.setTransactionPriority(priority);
1117 setState(() => selectedIdx = items.indexOf(priority));
1118 await output.calculateEstimatedFee();
1119 },
1120 );
1121 },
1122 );
1123 },
1124 );
1125 if (isBitcoinWallet) sendViewModel.feesViewModel.customBitcoinFeeRate = customFeeRate!.round();
1126 }
1127
1128 void _presentPicker(BuildContext context) {
1129 showPopUp<void>(
1130 context: context,
1131 builder: (_) => CurrencyPicker(
1132 key: ValueKey('send_page_currency_picker_dialog_button_key'),
1133 selectedAtIndex: sendViewModel.currencies.indexOf(sendViewModel.selectedCryptoCurrency),
1134 items: sendViewModel.currencies,
1135 hintText: S.of(context).search_currency,
1136 onItemSelected: (Currency cur) async {
1137 final selectedCurrency = sendViewModel.selectedCryptoCurrency = (cur as CryptoCurrency);
1138 await output.calculateEstimatedFee();
1139 return selectedCurrency;
1140 },
1141 ),
1142 );
1143 }
1144
1145 @override
1146 bool get wantKeepAlive => true;
1147 }