dev
dart 845 lines 39.7 KB
Raw
1 import 'package:cake_wallet/bitcoin/bitcoin.dart';
2 import 'package:cake_wallet/core/address_validator.dart';
3 import 'package:cake_wallet/core/auth_service.dart';
4 import 'package:cake_wallet/core/execution_state.dart';
5 import 'package:cake_wallet/entities/contact_record.dart';
6 import 'package:cake_wallet/entities/fiat_currency.dart';
7 import 'package:cake_wallet/entities/template.dart';
8 import 'package:cake_wallet/generated/i18n.dart';
9 import 'package:cake_wallet/monero/monero.dart';
10 import 'package:cake_wallet/reactions/wallet_connect.dart';
11 import 'package:cake_wallet/routes.dart';
12 import 'package:cake_wallet/src/screens/base_page.dart';
13 import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart';
14 import 'package:cake_wallet/src/screens/dashboard/widgets/sync_indicator_icon.dart';
15 import 'package:cake_wallet/src/screens/send/widgets/send_card.dart';
16 import 'package:cake_wallet/src/widgets/adaptable_page_view.dart';
17 import 'package:cake_wallet/src/widgets/add_template_button.dart';
18 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
19 import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
20 import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
21 import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
22 import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
23 import 'package:cake_wallet/src/widgets/keyboard_done_button.dart';
24 import 'package:cake_wallet/src/widgets/primary_button.dart';
25 import 'package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart';
26 import 'package:cake_wallet/src/widgets/simple_checkbox.dart';
27 import 'package:cake_wallet/src/widgets/template_tile.dart';
28 import 'package:cake_wallet/src/widgets/trail_button.dart';
29 import 'package:cake_wallet/utils/payment_request.dart';
30 import 'package:cake_wallet/utils/request_review_handler.dart';
31 import 'package:cake_wallet/utils/responsive_layout_util.dart';
32 import 'package:cake_wallet/utils/show_pop_up.dart';
33 import 'package:cake_wallet/view_model/payment/payment_view_model.dart';
34 import 'package:cake_wallet/view_model/send/output.dart';
35 import 'package:cake_wallet/view_model/send/send_view_model.dart';
36 import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
37 import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart';
38 import 'package:cw_core/amount/money.dart';
39 import 'package:cw_core/crypto_currency.dart';
40 import 'package:cw_core/utils/print_verbose.dart';
41 import 'package:cw_core/wallet_type.dart';
42 import 'package:flutter/material.dart';
43 import 'package:flutter_mobx/flutter_mobx.dart';
44 import 'package:keyboard_actions/keyboard_actions.dart';
45 import 'package:mobx/mobx.dart';
46 import 'package:smooth_page_indicator/smooth_page_indicator.dart';
47 import 'package:url_launcher/url_launcher.dart';
48
49 class SendPage extends BasePage {
50 SendPage({
51 required this.sendViewModel,
52 required this.authService,
53 required this.paymentViewModel,
54 required this.walletSwitcherViewModel,
55 this.initialPaymentRequest,
56 }) : _formKey = GlobalKey<FormState>();
57
58 final SendViewModel sendViewModel;
59 final PaymentViewModel paymentViewModel;
60 final WalletSwitcherViewModel walletSwitcherViewModel;
61 final AuthService authService;
62 final GlobalKey<FormState> _formKey;
63 final controller = PageController(initialPage: 0);
64 final PaymentRequest? initialPaymentRequest;
65 final FocusNode _cryptoAmountFocus = FocusNode();
66 final FocusNode _fiatAmountFocus = FocusNode();
67
68 final currentPage = ValueNotifier<int>(0);
69
70 bool _effectsInstalled = false;
71 ContactRecord? newContactAddress;
72
73 @override
74 String get title => S.current.send;
75
76 @override
77 bool get gradientAll => true;
78
79 @override
80 bool get resizeToAvoidBottomInset => false;
81
82 @override
83 bool get extendBodyBehindAppBar => true;
84
85 @override
86 Function(BuildContext)? get pushToNextWidget => (context) {
87 FocusScopeNode currentFocus = FocusScope.of(context);
88 if (!currentFocus.hasPrimaryFocus) {
89 currentFocus.focusedChild?.unfocus();
90 }
91 };
92
93 @override
94 Widget? leading(BuildContext context) {
95 final _backButton = Icon(
96 Icons.arrow_back_ios,
97 color: Theme.of(context).colorScheme.primary,
98 size: 16,
99 );
100 final _closeButton = currentTheme.isDark ? closeButtonImageDarkTheme : closeButtonImage;
101 final isMobileView = responsiveLayoutUtil.shouldRenderMobileUI;
102
103 return MergeSemantics(
104 child: SizedBox(
105 height: isMobileView ? 37 : 45,
106 width: isMobileView ? 47 : 45,
107 child: ButtonTheme(
108 minWidth: double.minPositive,
109 child: Semantics(
110 label: !isMobileView ? S.of(context).close : S.of(context).seed_alert_back,
111 child: TextButton(
112 style: ButtonStyle(
113 overlayColor: WidgetStateColor.resolveWith((states) => Colors.transparent),
114 ),
115 onPressed: () => onClose(context),
116 child: !isMobileView ? _closeButton : _backButton,
117 ),
118 ),
119 ),
120 ),
121 );
122 }
123
124 @override
125 AppBarStyle get appBarStyle => AppBarStyle.transparent;
126
127 @override
128 void onClose(BuildContext context) {
129 sendViewModel.onClose();
130 Navigator.of(context).pop();
131 }
132
133 @override
134 Widget? middle(BuildContext context) {
135 final supMiddle = super.middle(context);
136 return Row(
137 mainAxisAlignment: MainAxisAlignment.center,
138 children: [
139 Padding(
140 padding: const EdgeInsets.only(right: 8.0),
141 child: Observer(
142 builder: (_) => SyncIndicatorIcon(isSynced: sendViewModel.isReadyForSend),
143 ),
144 ),
145 if (supMiddle != null) supMiddle
146 ],
147 );
148 }
149
150 @override
151 Widget trailing(context) => Observer(
152 builder: (_) => sendViewModel.isBatchSending
153 ? TrailButton(
154 caption: S.of(context).remove,
155 onPressed: () {
156 var pageToJump = (controller.page?.round() ?? 0) - 1;
157 pageToJump = pageToJump > 0 ? pageToJump : 0;
158 final output = _defineCurrentOutput();
159 sendViewModel.removeOutput(output);
160 controller.jumpToPage(pageToJump);
161 },
162 )
163 : TrailButton(
164 caption: S.of(context).clear,
165 onPressed: () {
166 final output = _defineCurrentOutput();
167 _formKey.currentState?.reset();
168 output.reset();
169 },
170 ),
171 );
172
173 @override
174 Widget body(BuildContext context) {
175 _setEffects(context);
176
177 return ValueListenableBuilder(
178 valueListenable: currentPage,
179 builder: (context, value, child) {
180 return Observer(builder: (_) {
181 List<Widget> sendCards = [];
182 List<KeyboardActionsItem> keyboardActions = [];
183 for (final output in sendViewModel.outputs) {
184 final isCurrent = sendViewModel.outputs.indexOf(output) == value;
185 sendCards.add(
186 SendCard(
187 currentTheme: currentTheme,
188 key: output.key,
189 output: output,
190 sendViewModel: sendViewModel,
191 paymentViewModel: paymentViewModel,
192 walletSwitcherViewModel: walletSwitcherViewModel,
193 initialPaymentRequest: initialPaymentRequest,
194 cryptoAmountFocus: isCurrent ? _cryptoAmountFocus : null,
195 fiatAmountFocus: isCurrent ? _fiatAmountFocus : null,
196 ),
197 );
198 keyboardActions.add(
199 KeyboardActionsItem(
200 focusNode: _cryptoAmountFocus,
201 toolbarButtons: [(_) => KeyboardDoneButton()],
202 ),
203 );
204 keyboardActions.add(
205 KeyboardActionsItem(
206 focusNode: _fiatAmountFocus,
207 toolbarButtons: [(_) => KeyboardDoneButton()],
208 ),
209 );
210 }
211 return Stack(
212 children: [
213 KeyboardActions(
214 config: KeyboardActionsConfig(
215 keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
216 keyboardBarColor: Theme.of(context).colorScheme.surface,
217 nextFocus: false,
218 actions: keyboardActions,
219 ),
220 child: Container(
221 height: 0,
222 color: Colors.transparent,
223 ),
224 ),
225 GestureDetector(
226 onLongPress: () => sendViewModel.balanceViewModel.isReversing =
227 !sendViewModel.balanceViewModel.isReversing,
228 onLongPressUp: () => sendViewModel.balanceViewModel.isReversing =
229 !sendViewModel.balanceViewModel.isReversing,
230 child: RepaintBoundary(
231 child: Form(
232 key: _formKey,
233 child: ScrollableWithBottomSection(
234 contentPadding: EdgeInsets.only(bottom: 24),
235 content: FocusTraversalGroup(
236 policy: OrderedTraversalPolicy(),
237 child: Column(
238 children: <Widget>[
239 PageViewHeightAdaptable(
240 controller: controller,
241 children: sendCards,
242 ),
243 SizedBox(height: 10),
244 Padding(
245 padding: EdgeInsets.only(left: 24, right: 24, bottom: 10),
246 child: Container(
247 height: 10,
248 child: Observer(
249 builder: (_) {
250 final count = sendViewModel.outputs.length;
251
252 return count > 1
253 ? Semantics(
254 label: 'Page Indicator',
255 hint: 'Swipe to change receiver',
256 excludeSemantics: true,
257 child: SmoothPageIndicator(
258 controller: controller,
259 count: count,
260 effect: ScrollingDotsEffect(
261 spacing: 6.0,
262 radius: 6.0,
263 dotWidth: 6.0,
264 dotHeight: 6.0,
265 dotColor: Theme.of(context)
266 .colorScheme
267 .primary
268 .withAlpha(100),
269 activeDotColor:
270 Theme.of(context).colorScheme.primary,
271 ),
272 ),
273 )
274 : Offstage();
275 },
276 ),
277 ),
278 ),
279 Container(
280 height: 40,
281 width: double.infinity,
282 padding: EdgeInsets.only(left: 24),
283 child: SingleChildScrollView(
284 scrollDirection: Axis.horizontal,
285 child: Observer(
286 builder: (_) {
287 final templates = sendViewModel.templates;
288 final itemCount = templates.length;
289
290 return Row(
291 children: <Widget>[
292 AddTemplateButton(
293 key: ValueKey('send_page_add_template_button_key'),
294 onTap: () => Navigator.of(context)
295 .pushNamed(Routes.sendTemplate),
296 currentTemplatesLength: templates.length,
297 ),
298 ListView.builder(
299 scrollDirection: Axis.horizontal,
300 shrinkWrap: true,
301 physics: NeverScrollableScrollPhysics(),
302 itemCount: itemCount,
303 itemBuilder: (context, index) {
304 final template = templates[index];
305 return TemplateTile(
306 key: UniqueKey(),
307 to: template.name,
308 hasMultipleRecipients:
309 template.additionalRecipients != null &&
310 template.additionalRecipients!.length > 1,
311 amount: template.isCurrencySelected
312 ? template.amount
313 : template.amountFiat,
314 from: template.isCurrencySelected
315 ? template.cryptoCurrency
316 : template.fiatCurrency,
317 onTap: () async {
318 sendViewModel.state =
319 LoadingTemplateExecutingState();
320 if (template.additionalRecipients?.isNotEmpty ??
321 false) {
322 sendViewModel.clearOutputs();
323
324 for (int i = 0;
325 i < template.additionalRecipients!.length;
326 i++) {
327 Output output;
328 try {
329 output = sendViewModel.outputs[i];
330 } catch (e) {
331 sendViewModel.addOutput();
332 output = sendViewModel.outputs[i];
333 }
334
335 await _setInputsFromTemplate(
336 context,
337 output: output,
338 template:
339 template.additionalRecipients![i],
340 );
341 }
342 } else {
343 final output = _defineCurrentOutput();
344 await _setInputsFromTemplate(
345 context,
346 output: output,
347 template: template,
348 );
349 }
350 sendViewModel.state = InitialExecutionState();
351 },
352 onRemove: () {
353 showPopUp<void>(
354 context: context,
355 builder: (dialogContext) {
356 return AlertWithTwoActions(
357 alertTitle: S.of(context).template,
358 alertContent: S
359 .of(context)
360 .confirm_delete_template,
361 rightButtonText: S.of(context).delete,
362 leftButtonText: S.of(context).cancel,
363 actionRightButton: () {
364 Navigator.of(dialogContext).pop();
365 sendViewModel.sendTemplateViewModel
366 .removeTemplate(
367 template: template);
368 },
369 actionLeftButton: () =>
370 Navigator.of(dialogContext).pop());
371 },
372 );
373 },
374 );
375 },
376 ),
377 ],
378 );
379 },
380 ),
381 ),
382 ),
383 ],
384 ),
385 ),
386 bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24),
387 bottomSection: Column(
388 children: [
389 if (sendViewModel.sendTemplateViewModel.hasMultiRecipient)
390 Padding(
391 padding: EdgeInsets.only(bottom: 12),
392 child: PrimaryButton(
393 key: ValueKey('send_page_add_receiver_button_key'),
394 onPressed: () {
395 sendViewModel.addOutput();
396 Future.delayed(const Duration(milliseconds: 250), () {
397 controller.jumpToPage(sendViewModel.outputs.length - 1);
398 });
399 },
400 text: S.of(context).add_receiver,
401 color: Colors.transparent,
402 textColor: Theme.of(context).colorScheme.onSurfaceVariant,
403 isDottedBorder: true,
404 borderColor: Theme.of(context).colorScheme.outline,
405 )),
406 Observer(
407 builder: (_) {
408 return LoadingPrimaryButton(
409 key: ValueKey('send_page_send_button_key'),
410 onPressed: () async {
411 //Request dummy node to get the focus out of the text fields
412 FocusScope.of(context).requestFocus(FocusNode());
413
414 if (sendViewModel.state is IsExecutingState) return;
415 if (_formKey.currentState != null &&
416 !_formKey.currentState!.validate()) {
417 if (sendViewModel.outputs.length > 1) {
418 showErrorValidationAlert(context);
419 }
420
421 return;
422 }
423
424 final hasInvalidItems = sendViewModel.outputs.any((item) =>
425 item.address.isEmpty ||
426 (item.cryptoAmount.isEmpty && !item.sendAll));
427
428 if (hasInvalidItems) {
429 showErrorValidationAlert(context);
430 return;
431 }
432
433 if (sendViewModel.wallet.isHardwareWallet) {
434 if (!sendViewModel.hardwareWalletViewModel!
435 .isConnected(sendViewModel.walletType)) {
436 await Navigator.of(context)
437 .pushNamed(Routes.connectDevices,
438 arguments: ConnectDevicePageParams(
439 walletType: sendViewModel.walletType,
440 hardwareWalletType: sendViewModel
441 .wallet.walletInfo.hardwareWalletType!,
442 onConnectDevice: (BuildContext context, _) {
443 sendViewModel.hardwareWalletViewModel!
444 .initWallet(sendViewModel.wallet);
445 Navigator.of(context).pop();
446 },
447 ));
448 } else {
449 sendViewModel.hardwareWalletViewModel!
450 .initWallet(sendViewModel.wallet);
451 }
452 }
453
454 if (sendViewModel.wallet.type == WalletType.monero) {
455 var amount = Money.zero(sendViewModel.wallet.currency);
456 for (var item in sendViewModel.outputs) {
457 amount += item.cryptoAmountMoney;
458 }
459 if (monero!
460 .needExportOutputs(sendViewModel.wallet, amount)) {
461 await Navigator.of(context).pushNamed(
462 Routes.urqrAnimatedPage,
463 arguments:
464 monero!.exportOutputsUR(sendViewModel.wallet));
465 await Future.delayed(Duration(
466 seconds: 1)); // wait for monero to refresh the state
467 }
468 if (monero!
469 .needExportOutputs(sendViewModel.wallet, amount)) {
470 return;
471 }
472 }
473
474 final check = sendViewModel.shouldDisplayTotp();
475 authService.authenticateAction(
476 context,
477 conditionToDetermineIfToUse2FA: check,
478 onAuthSuccess: (value) async {
479 if (value) {
480 await sendViewModel.createTransaction();
481 }
482 },
483 );
484 },
485 text: _sendButtonText(context),
486 color: Theme.of(context).colorScheme.primary,
487 textColor: Theme.of(context).colorScheme.onPrimary,
488 isLoading: sendViewModel.state is IsExecutingState ||
489 sendViewModel.state is TransactionCommitting ||
490 sendViewModel.state is IsAwaitingDeviceResponseState ||
491 sendViewModel.state is LoadingTemplateExecutingState,
492 isDisabled: !sendViewModel.isReadyForSend ||
493 sendViewModel.state is ExecutedSuccessfullyState,
494 );
495 },
496 )
497 ],
498 )),
499 ),
500 ),
501 ),
502 ],
503 );
504 });
505 });
506 }
507
508 BuildContext? dialogContext;
509 BuildContext? loadingBottomSheetContext;
510
511 void _setEffects(BuildContext context) {
512 if (_effectsInstalled) return;
513
514 if (sendViewModel.isElectrumWallet) {
515 bitcoin!.updateFeeRates(sendViewModel.wallet);
516 }
517
518 controller.addListener(() {
519 if (!controller.hasClients) return;
520 currentPage.value = controller.page!.round();
521 });
522
523 reaction((_) => sendViewModel.state, (ExecutionState state) async {
524 if (dialogContext != null && dialogContext?.mounted == true) {
525 Navigator.of(dialogContext!).pop();
526 }
527
528 if (state is! IsExecutingState &&
529 loadingBottomSheetContext != null &&
530 loadingBottomSheetContext!.mounted) {
531 Navigator.of(loadingBottomSheetContext!).pop();
532 }
533
534 if (state is FailureState) {
535 WidgetsBinding.instance.addPostFrameCallback(
536 (_) {
537 showPopUp<void>(
538 context: context,
539 builder: (context) => AlertWithOneAction(
540 key: ValueKey('send_page_send_failure_dialog_key'),
541 buttonKey: ValueKey('send_page_send_failure_dialog_button_key'),
542 alertTitle: S.of(context).error,
543 alertContent: state.error,
544 buttonText: S.of(context).ok,
545 buttonAction: () => Navigator.of(context).pop(),
546 ),
547 );
548 },
549 );
550 }
551
552 if (state is IsExecutingState) {
553 // wait a bit to avoid showing the loading dialog if transaction is failed
554 await Future.delayed(const Duration(milliseconds: 300));
555 final currentState = sendViewModel.state;
556 if (currentState is ExecutedSuccessfullyState || currentState is FailureState) {
557 return;
558 }
559
560 WidgetsBinding.instance.addPostFrameCallback((_) {
561 if (context.mounted) {
562 showModalBottomSheet<void>(
563 context: context,
564 isDismissible: false,
565 builder: (context) {
566 loadingBottomSheetContext = context;
567 return LoadingBottomSheet(
568 titleText: S.of(context).generating_transaction,
569 );
570 },
571 );
572 }
573 });
574 }
575
576 if (state is ExecutedSuccessfullyState) {
577 WidgetsBinding.instance.addPostFrameCallback((_) async {
578 if (context.mounted) {
579 final result = await showModalBottomSheet<bool>(
580 context: context,
581 isDismissible: false,
582 isScrollControlled: true,
583 builder: (BuildContext bottomSheetContext) {
584 return Observer(
585 builder: (_) => ConfirmSendingBottomSheet(
586 key: ValueKey('send_page_confirm_sending_bottom_sheet_key'),
587 titleText: S.of(bottomSheetContext).confirm_transaction,
588 accessibleNavigationModeSlideActionButtonText: S.of(bottomSheetContext).send,
589 footerType: FooterType.slideActionButton,
590 isSlideActionEnabled: sendViewModel.isReadyForSend,
591 walletType: sendViewModel.walletType,
592 titleIconPath: sendViewModel.selectedCryptoCurrency.iconPath,
593 currency: sendViewModel.selectedCryptoCurrency,
594 amount: S.of(bottomSheetContext).send_amount,
595 amountValue: sendViewModel.amountParsingProxy.getDisplayCryptoAmount(
596 sendViewModel.pendingTransaction!.amountFormatted,
597 sendViewModel.selectedCryptoCurrency),
598 fiatAmountValue: sendViewModel.pendingTransactionFiatAmountFormatted,
599 fee: isEVMCompatibleChain(sendViewModel.walletType)
600 ? S.of(bottomSheetContext).send_estimated_fee
601 : S.of(bottomSheetContext).send_fee,
602 feeValue:
603 "${sendViewModel.amountParsingProxy.getDisplayCryptoAmount(sendViewModel.pendingTransaction!.feeFormattedValue, sendViewModel.selectedCryptoCurrency)} ${sendViewModel.amountParsingProxy.getCryptoSymbol(sendViewModel.wallet.currency)}",
604 feeFiatAmount: sendViewModel.pendingTransactionFeeFiatAmountFormatted,
605 outputs: sendViewModel.outputs,
606 onSlideActionComplete: () async {
607 Navigator.of(bottomSheetContext).pop(true);
608 sendViewModel.commitTransaction(context);
609 },
610 change: sendViewModel.pendingTransaction!.change,
611 isOpenCryptoPay: sendViewModel.ocpRequest != null,
612 amountParsingProxy: sendViewModel.amountParsingProxy,
613 ),
614 );
615 },
616 );
617
618 if (result == null) sendViewModel.dismissTransaction();
619 }
620 });
621 }
622
623 if (state is TransactionCommitted) {
624 WidgetsBinding.instance.addPostFrameCallback((_) async {
625 if (!context.mounted) return;
626
627 newContactAddress = newContactAddress ?? sendViewModel.newContactAddress();
628
629 if (newContactAddress?.address != null &&
630 isRegularElectrumAddress(newContactAddress!.address)) {
631 newContactAddress = null;
632 }
633
634 bool showContactSheet = (newContactAddress != null && sendViewModel.showAddressBookPopup);
635
636 await showModalBottomSheet<void>(
637 context: context,
638 isDismissible: false,
639 builder: (BuildContext bottomSheetContext) {
640 return showContactSheet && sendViewModel.ocpRequest == null
641 ? InfoBottomSheet(
642 footerType: FooterType.doubleActionButton,
643 titleText: S.of(bottomSheetContext).transaction_sent,
644 contentImage: 'assets/images/contact.png',
645 contentImageColor: Theme.of(context).colorScheme.onSurface,
646 content: S.of(bottomSheetContext).add_contact_to_address_book,
647 leftActionButtonKey:
648 ValueKey('send_page_add_contact_bottom_sheet_no_button_key'),
649 rightActionButtonKey:
650 ValueKey('send_page_add_contact_bottom_sheet_yes_button_key'),
651 bottomActionPanel: Padding(
652 padding: const EdgeInsets.only(left: 34.0),
653 child: Row(
654 children: [
655 SimpleCheckbox(
656 onChanged: (value) =>
657 sendViewModel.setShowAddressBookPopup(!value)),
658 const SizedBox(width: 8),
659 Text(
660 'Don’t ask me next time',
661 textAlign: TextAlign.center,
662 style: TextStyle(
663 fontSize: 14,
664 fontFamily: 'Lato',
665 fontWeight: FontWeight.w500,
666 color: Theme.of(context).textTheme.titleLarge!.color,
667 decoration: TextDecoration.none,
668 ),
669 ),
670 ],
671 ),
672 ),
673 doubleActionLeftButtonText: 'No',
674 doubleActionRightButtonText: 'Yes',
675 onLeftActionButtonPressed: () {
676 Navigator.of(bottomSheetContext).pop();
677 if (context.mounted) {
678 Navigator.of(context)
679 .pushNamedAndRemoveUntil(Routes.dashboard, (route) => false);
680 }
681 RequestReviewHandler.requestReview();
682 newContactAddress = null;
683 },
684 onRightActionButtonPressed: () {
685 Navigator.of(bottomSheetContext).pop();
686 RequestReviewHandler.requestReview();
687 if (context.mounted) {
688 Navigator.of(context).pushNamed(Routes.addressBookAddContact,
689 arguments: newContactAddress);
690 }
691 newContactAddress = null;
692 },
693 )
694 : InfoBottomSheet(
695 footerType: FooterType.singleActionButton,
696 titleText: S.of(bottomSheetContext).transaction_sent,
697 contentImage: 'assets/images/birthday_cake.png',
698 singleActionButtonText: S.of(bottomSheetContext).close,
699 singleActionButtonKey: ValueKey('send_page_transaction_sent_button_key'),
700 onSingleActionButtonPressed: () {
701 Navigator.of(bottomSheetContext).pop();
702 Future.delayed(Duration.zero, () {
703 if (context.mounted) {
704 Navigator.of(context)
705 .pushNamedAndRemoveUntil(Routes.dashboard, (route) => false);
706 }
707 RequestReviewHandler.requestReview();
708 newContactAddress = null;
709 });
710 },
711 );
712 },
713 );
714
715 if (initialPaymentRequest?.callbackUrl?.isNotEmpty ?? false) {
716 // wait a second so it's not as jarring:
717 await Future.delayed(Duration(seconds: 1));
718 try {
719 launchUrl(
720 Uri.parse(initialPaymentRequest!.callbackUrl!),
721 mode: LaunchMode.externalApplication,
722 );
723 } catch (e) {
724 printV(e);
725 }
726 }
727
728 sendViewModel.clearOutputs();
729 });
730 }
731
732 if (state is IsDeviceSigningResponseState) {
733 WidgetsBinding.instance.addPostFrameCallback((_) {
734 if (!context.mounted) return;
735
736 showModalBottomSheet<void>(
737 context: context,
738 isDismissible: false,
739 builder: (context) {
740 dialogContext = context;
741 return LoadingBottomSheet(titleText: S.of(context).processing_signed_tx);
742 },
743 );
744 });
745 }
746
747 if (state is IsAwaitingDeviceResponseState) {
748 WidgetsBinding.instance.addPostFrameCallback((_) {
749 if (!context.mounted) return;
750
751 showModalBottomSheet<void>(
752 context: context,
753 isDismissible: false,
754 builder: (context) {
755 dialogContext = context;
756 return InfoBottomSheet(
757 footerType: FooterType.singleActionButton,
758 titleText: S.of(context).proceed_on_device,
759 contentImage: 'assets/images/hardware_wallet/ledger_nano_x.png',
760 contentImageColor: Theme.of(context).colorScheme.onSurface,
761 content: S.of(context).proceed_on_device_description,
762 singleActionButtonText: S.of(context).cancel,
763 onSingleActionButtonPressed: () {
764 sendViewModel.state = InitialExecutionState();
765 Navigator.of(context).pop();
766 },
767 );
768 });
769 });
770 }
771 });
772
773 _effectsInstalled = true;
774 }
775
776 Future<void> _setInputsFromTemplate(BuildContext context,
777 {required Output output, required Template template}) async {
778 output.address = template.address;
779
780 if (template.isCurrencySelected) {
781 sendViewModel.setSelectedCryptoCurrency(template.cryptoCurrency);
782 output.setCryptoAmount(template.amount);
783 } else {
784 final fiatFromTemplate =
785 FiatCurrency.all.singleWhere((element) => element.title == template.fiatCurrency);
786
787 sendViewModel.setFiatCurrency(fiatFromTemplate);
788 output.setFiatAmount(template.amountFiat);
789 }
790
791 output.resetParsedAddress();
792 }
793
794 Output _defineCurrentOutput() {
795 if (controller.page == null) throw Exception('Controller page is null');
796 final itemCount = controller.page!.round();
797 return sendViewModel.outputs[itemCount];
798 }
799
800 void showErrorValidationAlert(BuildContext context) => showPopUp<void>(
801 context: context,
802 builder: (context) => AlertWithOneAction(
803 alertTitle: S.of(context).error,
804 alertContent: 'Please, check receiver forms',
805 buttonText: S.of(context).ok,
806 buttonAction: () => Navigator.of(context).pop(),
807 ),
808 );
809
810 bool isRegularElectrumAddress(String address) {
811 final supportedTypes = [CryptoCurrency.btc, CryptoCurrency.ltc, CryptoCurrency.bch];
812 final excludedPatterns = [
813 RegExp(AddressValidator.silentPaymentAddressPatternMainnet),
814 RegExp(AddressValidator.silentPaymentAddressPatternTestnet),
815 RegExp(AddressValidator.mWebAddressPattern),
816 RegExp(AddressValidator.bolt11InvoiceMatcher),
817 ];
818
819 final trimmed = address.trim();
820
821 bool isValid = false;
822 for (final type in supportedTypes) {
823 final addressPattern = AddressValidator.getAddressFromStringPattern(type);
824 if (addressPattern != null) {
825 final regex = RegExp('^$addressPattern\$');
826 if (regex.hasMatch(trimmed)) {
827 isValid = true;
828 break;
829 }
830 }
831 }
832
833 for (final pattern in excludedPatterns) {
834 if (pattern.hasMatch(trimmed)) return false;
835 }
836
837 return isValid;
838 }
839
840 String _sendButtonText(BuildContext context) {
841 if (!sendViewModel.isReadyForSend) return S.of(context).synchronizing;
842 if (sendViewModel.payjoinUri != null) return S.of(context).send_payjoin;
843 return S.of(context).send;
844 }
845 }