dev
dart 1,412 lines 60.4 KB
Raw
1 import "dart:async";
2
3 import "package:cake_wallet/core/address_resolver/parsed_address.dart";
4 import "package:cake_wallet/core/address_validator.dart";
5 import "package:cake_wallet/core/anypay/anypay_models.dart";
6 import "package:cake_wallet/core/anypay/anypay_parser.dart";
7 import "package:cake_wallet/core/anypay/anypay_service.dart";
8 import "package:cake_wallet/core/auth_service.dart";
9 import "package:cake_wallet/core/execution_state.dart";
10 import "package:cake_wallet/core/open_crypto_pay/open_cryptopay_service.dart";
11 import "package:cake_wallet/entities/contact_record.dart";
12 import "package:cake_wallet/entities/priority_for_wallet_type.dart";
13 import "package:cake_wallet/evm/evm.dart";
14 import "package:cake_wallet/generated/i18n.dart";
15 import "package:cake_wallet/main.dart";
16 import "package:cake_wallet/monero/monero.dart";
17 import "package:cake_wallet/new-ui/modal_navigator.dart";
18 import "package:cake_wallet/new-ui/pages/coin_control_page.dart";
19 import "package:cake_wallet/new-ui/widgets/animated_dropdown.dart";
20 import "package:cake_wallet/new-ui/widgets/anypay/anypay_flow.dart";
21 import "package:cake_wallet/new-ui/widgets/anypay/recipient_network_row.dart";
22 import "package:cake_wallet/new-ui/widgets/anypay/select_recipient_network_sheet.dart";
23 import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
24 import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart";
25 import "package:cake_wallet/new-ui/widgets/currency_picker/fiat_currency_picker_sheet.dart";
26 import "package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart";
27 import "package:cake_wallet/new-ui/widgets/modern_button.dart";
28 import "package:cake_wallet/new-ui/widgets/new_future_primary_button.dart";
29 import "package:cake_wallet/new-ui/widgets/new_primary_button.dart";
30 import "package:cake_wallet/new-ui/widgets/picker.dart";
31 import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
32 import "package:cake_wallet/new-ui/widgets/send_page/directional_switcher.dart";
33 import "package:cake_wallet/new-ui/widgets/send_page/fiat_amount_bar.dart";
34 import "package:cake_wallet/new-ui/widgets/send_page/l2_action_wallet_selector.dart";
35 import "package:cake_wallet/new-ui/widgets/send_page/recipient_dot_row.dart";
36 import "package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart";
37 import "package:cake_wallet/new-ui/widgets/send_page/send_amount_input.dart";
38 import "package:cake_wallet/new-ui/widgets/send_page/send_confirm_sheet.dart";
39 import "package:cake_wallet/new-ui/widgets/send_page/send_memo_input.dart";
40 import "package:cake_wallet/new-ui/widgets/send_page/send_syncing_indicator.dart";
41 import "package:cake_wallet/reactions/wallet_connect.dart";
42 import "package:cake_wallet/routes.dart" show Routes;
43 import "package:cake_wallet/src/screens/connect_device/connect_device_page.dart";
44 import "package:cake_wallet/src/widgets/alert_with_one_action.dart";
45 import "package:cake_wallet/src/widgets/cake_image_widget.dart";
46 import "package:cake_wallet/src/widgets/new_list_row/list_item_regular_row_widget.dart";
47 import "package:cake_wallet/src/widgets/standard_checkbox.dart";
48 import "package:cake_wallet/utils/payment_request.dart";
49 import "package:cake_wallet/utils/show_pop_up.dart";
50 import "package:cake_wallet/view_model/contact_list/contact_list_view_model.dart";
51 import "package:cake_wallet/view_model/link_view_model.dart";
52 import "package:cake_wallet/view_model/send/output.dart";
53 import "package:cake_wallet/view_model/send/send_view_model.dart";
54 import "package:cake_wallet/view_model/send/send_view_model_state.dart";
55 import "package:cake_wallet/view_model/wallet_switcher_view_model.dart";
56 import "package:cw_core/amount/amount_sanitizer.dart";
57 import "package:cw_core/amount/money.dart";
58 import "package:cw_core/crypto_currency.dart";
59 import "package:cw_core/currency_for_wallet_type.dart";
60 import "package:cw_core/erc20_token.dart";
61 import "package:cw_core/lnurl.dart";
62 import "package:cw_core/transaction_priority.dart";
63 import "package:cw_core/unspent_coin_type.dart";
64 import "package:cw_core/utils/print_verbose.dart";
65 import "package:cw_core/wallet_type.dart";
66 import "package:flutter/cupertino.dart";
67 import "package:flutter/material.dart";
68 import "package:flutter_mobx/flutter_mobx.dart";
69 import "package:mobx/mobx.dart";
70 import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
71
72 class SendPageHelpContent {
73 const SendPageHelpContent({
74 required this.title,
75 required this.imagePath,
76 required this.description,
77 this.disclaimer,
78 });
79
80 final String imagePath;
81 final String title;
82 final String description;
83 final String? disclaimer;
84 }
85
86 class SendPageModes {
87 const SendPageModes({
88 required this.title,
89 required this.showAddressField,
90 this.description,
91 this.confirmSheetIconPath,
92 this.helpContent,
93 this.popOnConfirmation = true,
94 });
95
96 final bool showAddressField;
97 final String title;
98 final String? description;
99 final String? confirmSheetIconPath;
100 final SendPageHelpContent? helpContent;
101 final bool popOnConfirmation;
102
103 static final SendPageModes normal = SendPageModes(title: S.current.send, showAddressField: true);
104
105 static final SendPageModes lightningDeposit = SendPageModes(
106 title: S.current.bitcoin_lightning_deposit,
107 description: S.current.to_lightning,
108 showAddressField: false,
109 helpContent: SendPageHelpContent(
110 title: S.current.bitcoin_lightning_deposit,
111 imagePath: "assets/new-ui/lightning_deposit_help.svg",
112 description: S.current.lightning_deposit_desc,
113 disclaimer: S.current.lightning_deposit_disclaimer,
114 ),
115 popOnConfirmation: false,
116 );
117
118 static final SendPageModes lightningWithdrawal = SendPageModes(
119 title: S.current.bitcoin_lightning_withdraw,
120 description: S.current.to_on_chain,
121 showAddressField: false,
122 helpContent: SendPageHelpContent(
123 title: S.current.bitcoin_lightning_withdraw,
124 imagePath: "assets/new-ui/lightning_withdraw_help.svg",
125 description: S.current.lightning_withdraw_desc,
126 disclaimer: S.current.lightning_withdraw_disclaimer,
127 ),
128 popOnConfirmation: false,
129 );
130
131 static final SendPageModes mwebDeposit = SendPageModes(
132 title: "${S.current.mask} Litecoin",
133 showAddressField: false,
134 confirmSheetIconPath: "assets/new-ui/mask.svg",
135 helpContent: SendPageHelpContent(
136 title: S.current.about_litecoin_privacy,
137 imagePath: "assets/new-ui/mweb_help.svg",
138 description: "${S.current.mweb_help_desc_1}\n\n${S.current.mweb_help_desc_2}",
139 disclaimer: S.current.mweb_help_disclaimer,
140 ),
141 popOnConfirmation: false,
142 );
143
144 static final SendPageModes mwebWithdrawal = SendPageModes(
145 title: "${S.current.unmask} Litecoin",
146 showAddressField: false,
147 confirmSheetIconPath: "assets/new-ui/unmask.svg",
148 helpContent: SendPageHelpContent(
149 title: S.current.about_litecoin_privacy,
150 imagePath: "assets/new-ui/mweb_help.svg",
151 description: "${S.current.mweb_help_desc_1}\n\n${S.current.mweb_help_desc_2}",
152 disclaimer: S.current.mweb_help_disclaimer,
153 ),
154 popOnConfirmation: false,
155 );
156
157 static final all = [normal, lightningDeposit, lightningWithdrawal, mwebDeposit, mwebWithdrawal];
158 }
159
160 class SendPageParams {
161 SendPageParams({
162 this.initialPaymentRequest,
163 this.initialRawInput,
164 SendPageModes? mode,
165 this.unspentCoinType = UnspentCoinType.any,
166 this.initialCurrency,
167 }) : mode = mode ?? SendPageModes.normal;
168
169 final PaymentRequest? initialPaymentRequest;
170 final String? initialRawInput;
171 final SendPageModes mode;
172 final CryptoCurrency? initialCurrency;
173 final UnspentCoinType unspentCoinType;
174 }
175
176 class NewSendPage extends StatefulWidget {
177 NewSendPage({
178 required this.sendViewModel,
179 required this.anyPayService,
180 required this.linkViewModel,
181 required this.walletSwitcherViewModel,
182 required this.contactListViewModel,
183 required this.authService,
184 required SendPageParams params,
185 super.key,
186 }) : initialPaymentRequest = params.initialPaymentRequest,
187 initialRawInput = params.initialRawInput,
188 mode = params.mode {
189 if (params.initialCurrency != null) {
190 sendViewModel.selectedCryptoCurrency = params.initialCurrency!;
191 }
192 }
193
194 final SendViewModel sendViewModel;
195 final AnyPayService anyPayService;
196 final LinkViewModel linkViewModel;
197 final WalletSwitcherViewModel walletSwitcherViewModel;
198 final ContactListViewModel contactListViewModel;
199 final AuthService authService;
200 final PaymentRequest? initialPaymentRequest;
201 final String? initialRawInput;
202 final SendPageModes mode;
203
204 @override
205 State<NewSendPage> createState() => _NewSendPageState();
206 }
207
208 class _NewSendPageState extends State<NewSendPage> {
209 int _selectedOutput = 0;
210
211 final _amountControllers = <TextEditingController>[];
212 final _addressControllers = <TextEditingController>[];
213 final _memoControllers = <TextEditingController>[];
214 final _formKey = GlobalKey<FormState>();
215 final _addressFocusNode = FocusNode();
216 final _disposers = <ReactionDisposer>[];
217 StreamSubscription<Uri>? _deepLinkSubscription;
218 late final AnyPayFlow _anyPayFlow;
219 ContactRecord? newContactAddress;
220
221 bool _justHandledPasteButton = false;
222
223 @override
224 void initState() {
225 super.initState();
226 _anyPayFlow = AnyPayFlow(
227 anyPayService: widget.anyPayService,
228 sendViewModel: widget.sendViewModel,
229 authService: widget.authService,
230 walletSwitcherViewModel: widget.walletSwitcherViewModel,
231 );
232 _addInputControllers();
233
234 _disposers.add(
235 reaction((_) => widget.sendViewModel.outputs[_selectedOutput].sendAll, (all) {
236 if (all) {
237 widget.sendViewModel.outputs[_selectedOutput].isFiatEntry = false;
238 _amountControllers[_selectedOutput].text = S.current.all;
239 }
240 }),
241 );
242
243 _disposers.add(
244 reaction((_) => widget.sendViewModel.outputs[_selectedOutput].address, (address) {
245 if (_addressControllers[_selectedOutput].text != address) {
246 _addressControllers[_selectedOutput].text = address;
247 }
248 }),
249 );
250
251 _disposers.add(
252 reaction((_) => widget.sendViewModel.outputs[_selectedOutput].memo, (memo) {
253 if (memo != _memoControllers[_selectedOutput].text) {
254 _memoControllers[_selectedOutput].text = memo;
255 }
256 }),
257 );
258
259 if (widget.initialRawInput != null || widget.initialPaymentRequest != null) {
260 WidgetsBinding.instance.addPostFrameCallback((_) async {
261 if (!mounted) {
262 return;
263 }
264 await _runAnyPayFlow(
265 rawInput: widget.initialRawInput,
266 paymentRequest: widget.initialPaymentRequest,
267 );
268 });
269 }
270
271 if (widget.mode == SendPageModes.normal) {
272 _deepLinkSubscription = widget.linkViewModel.incomingPaymentLinks.listen((link) async {
273 if (!mounted) {
274 return;
275 }
276
277 final state = widget.sendViewModel.state;
278 if (_anyPayFlow.isSwitchingWallet ||
279 state is IsExecutingState ||
280 state is TransactionCommitting) {
281 printV("dropping incoming payment link while the send flow is busy");
282 return;
283 }
284
285 final route = ModalRoute.of(context);
286 if (route != null && !route.isCurrent) {
287 Navigator.of(context).popUntil((r) => r == route);
288 }
289
290 await _runAnyPayFlow(rawInput: link.toString());
291 });
292 }
293
294 _addressFocusNode.addListener(() async {
295 if (!_addressFocusNode.hasFocus && _addressControllers[_selectedOutput].text.isNotEmpty) {
296 final output = widget.sendViewModel.outputs[_selectedOutput];
297 await _resolveAddressForOutput(output);
298 }
299 });
300 }
301
302 @override
303 void dispose() {
304 _deepLinkSubscription?.cancel();
305 for (final disposer in _disposers) {
306 disposer();
307 }
308 for (final controller in _amountControllers) {
309 controller.dispose();
310 }
311 for (final controller in _addressControllers) {
312 controller.dispose();
313 }
314 for (final controller in _memoControllers) {
315 controller.dispose();
316 }
317 _addressFocusNode.dispose();
318 super.dispose();
319 }
320
321 Future<void> _resolveAddressForOutput(Output output) async {
322 final result = await widget.sendViewModel.resolveAddressForOutput(output);
323 if (result == null || !mounted) {
324 return;
325 }
326
327 final confirmed = await showParsedAddressConfirmationAlert(context, result);
328
329 if (confirmed) {
330 output.applyAddressLookupResult(result);
331 } else {
332 output.resetParsedAddress();
333 return;
334 }
335
336 final outputIndex = widget.sendViewModel.outputs.indexOf(output);
337 if (outputIndex >= 0 &&
338 outputIndex < _addressControllers.length &&
339 output.extractedAddress.isNotEmpty &&
340 _addressControllers[outputIndex].text != output.extractedAddress) {
341 _addressControllers[outputIndex].text = output.extractedAddress;
342 }
343 }
344
345 @override
346 Widget build(BuildContext context) => Observer(
347 builder: (_) {
348 final output = widget.sendViewModel.outputs[_selectedOutput];
349 final recipientChain = widget.sendViewModel.isEVMWallet
350 ? evm!.getChainInfoByChainId(_currentEvmChainIdOrMainnet())
351 : null;
352 return SafeArea(
353 bottom: false,
354 child: KeyboardHideOverlay(
355 unfocusOnTap: true,
356 child: Container(
357 decoration: BoxDecoration(
358 color: Theme.of(context).colorScheme.surface,
359 borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
360 ),
361 child: SafeArea(
362 child: Column(
363 spacing: 12,
364 mainAxisSize: MainAxisSize.max,
365 children: [
366 ModalTopBar(
367 title: widget.mode.title,
368 subtitle: widget.mode.description,
369 leadingIcon: const Icon(Icons.close),
370 leadingSemanticLabel: S.of(context).close,
371 onLeadingPressed: Navigator.of(context, rootNavigator: true).pop,
372 trailingWidget: Observer(
373 builder: (_) => Row(
374 spacing: 8,
375 children: [
376 if (widget.sendViewModel.outputs.length > 1)
377 ModernButton(
378 size: 36,
379 icon: CakeImageWidget(
380 imageUrl: "assets/new-ui/remove_recipient.svg",
381 colorFilter: ColorFilter.mode(
382 Theme.of(context).colorScheme.primary,
383 BlendMode.srcIn,
384 ),
385 ),
386 semanticLabel: S.of(context).remove,
387 onPressed: () {
388 final outputIndex = _selectedOutput;
389 if (_selectedOutput != 0) {
390 _setOutput(_selectedOutput - 1);
391 } else {
392 _setOutput(1);
393 }
394 _removeInputControllers(outputIndex);
395 widget.sendViewModel.removeOutput(output);
396 if (outputIndex == 0) {
397 _setOutput(0);
398 }
399 },
400 ),
401 if (widget.mode == SendPageModes.normal &&
402 widget.sendViewModel.hasMultiRecipient)
403 ModernButton(
404 size: 36,
405 icon: const Icon(Icons.add),
406 semanticLabel: S.of(context).add_receiver,
407 onPressed: () {
408 _addInputControllers();
409 widget.sendViewModel.addOutput();
410 _setOutput(widget.sendViewModel.outputs.length - 1);
411 },
412 ),
413 if (widget.mode.helpContent != null)
414 ModernButton(
415 size: 36,
416 icon: CakeImageWidget(
417 imageUrl: "assets/new-ui/help.svg",
418 colorFilter: ColorFilter.mode(
419 Theme.of(context).colorScheme.primary,
420 BlendMode.srcIn,
421 ),
422 ),
423 semanticLabel: S.of(context).help,
424 onPressed: () {
425 Navigator.of(context).push(
426 CupertinoPageRoute(
427 builder: (context) => Material(
428 child: SendHelpPage(content: widget.mode.helpContent!),
429 ),
430 ),
431 );
432 },
433 ),
434 ],
435 ),
436 ),
437 ),
438 Expanded(
439 child: Padding(
440 padding: const EdgeInsets.symmetric(horizontal: 16),
441 child: Column(
442 mainAxisAlignment: MainAxisAlignment.spaceBetween,
443 mainAxisSize: MainAxisSize.max,
444 children: [
445 Form(
446 key: _formKey,
447 child: DirectionalAnimatedSwitcher(
448 duration: const Duration(milliseconds: 300),
449 child: Column(
450 key: ValueKey(_selectedOutput),
451 spacing: 24,
452 crossAxisAlignment: CrossAxisAlignment.start,
453 children: [
454 if (widget.mode.showAddressField)
455 Column(
456 crossAxisAlignment: CrossAxisAlignment.start,
457 spacing: 12,
458 children: [
459 // NewSendAddressInput merges this label onto its
460 // own text-field node, so announcing the caption
461 // as well would read it twice.
462 ExcludeSemantics(
463 child: Text(S.of(context).address_or_alias),
464 ),
465 NewSendAddressInput(
466 displayName: output.displayName,
467 validator: output.isParsedAddress
468 ? widget.sendViewModel.textValidator
469 : widget.sendViewModel.addressValidator,
470 addressController:
471 _addressControllers[_selectedOutput],
472 focusNode: _addressFocusNode,
473 onURIScanned: (uri) async {
474 output.resetParsedAddress();
475 await _resolveAddressForOutput(output);
476
477 await _runAnyPayFlow(rawInput: uri.toString());
478 },
479 onEditingComplete: _addressFocusNode.unfocus,
480 onPushAddressBookButton: (_) {
481 output.resetParsedAddress();
482 },
483 onSelectedContact: (contact) {
484 output.loadContact(contact);
485 },
486 onPushPasteButton: (context) async {
487 if (_justHandledPasteButton) {
488 return;
489 }
490 _justHandledPasteButton = true;
491 try {
492 output.resetParsedAddress();
493 await _resolveAddressForOutput(output);
494
495 final address = output.isParsedAddress
496 ? output.extractedAddress
497 : output.address;
498
499 await _runAnyPayPaste(address);
500 } finally {
501 _justHandledPasteButton = false;
502 }
503
504 _handleLightningInvoicePaste();
505 },
506 selectedCurrency:
507 widget.sendViewModel.selectedCryptoCurrency,
508 ),
509 if (widget.sendViewModel.isEVMWallet &&
510 _hasEvmRecipient(output) &&
511 recipientChain != null)
512 RecipientNetworkSelector(
513 networkName: recipientChain.name,
514 networkIconPath: symbolIconPathForWalletType(
515 widget.sendViewModel.wallet.type,
516 ) ??
517 "",
518 onTap: () => _presentRecipientNetworkPicker(
519 recipientChain,
520 ),
521 ),
522 ],
523 ),
524 Column(
525 crossAxisAlignment: CrossAxisAlignment.start,
526 spacing: 12,
527 children: [
528 // This caption is the amount field's accessible name
529 // and must stay in the semantics tree:
530 // NewSendAmountInput deliberately carries no label of
531 // its own, because labelling the field made Android
532 // announce the amount twice. Covered by
533 // test/new-ui/widgets/send_page/send_amount_input_test.dart.
534 Text(S.of(context).amount),
535 NewSendAmountInput(
536 validator: output.sendAll
537 ? widget.sendViewModel.allAmountValidator
538 : widget.sendViewModel.amountValidator(output),
539 amountController: _amountControllers[_selectedOutput],
540 currency: output.isFiatEntry
541 ? widget.sendViewModel.fiatCurrency.title
542 : widget.sendViewModel.selectedCryptoCurrencySymbol,
543 currencyIconPath: output.isFiatEntry
544 ? ""
545 : widget.sendViewModel.selectedCryptoCurrency
546 .iconPath ??
547 "",
548 hasPicker: output.isFiatEntry ||
549 widget.sendViewModel.hasMultipleTokens,
550 onPickerClicked: () => _presentCurrencyPicker(context),
551 maxDecimals: output.isFiatEntry
552 ? widget.sendViewModel.fiatCurrency.decimals
553 : widget.sendViewModel.useBaseUnits
554 ? 0
555 : widget.sendViewModel.selectedCryptoCurrency
556 .decimals,
557 ),
558 FiatAmountBar(
559 fiatInputMode: output.isFiatEntry,
560 onSwitchButtonPressed: _onFiatSwitchPressed,
561 fiatAmount:
562 _wrapAmount(output.roundedFiatAmount(6), 20),
563 cryptoAmount:
564 _wrapAmount(output.roundedCryptoAmount(6), 20),
565 allAmount: widget.sendViewModel.balance,
566 cryptoCurrencySymbol:
567 widget.sendViewModel.selectedCryptoCurrencySymbol,
568 fiatCurrencySymbol:
569 widget.sendViewModel.fiatCurrency.symbol,
570 onAllButtonPressed: () async {
571 output.setSendAll(
572 await widget.sendViewModel.sendingBalance,
573 );
574 await output.calculateEstimatedFee();
575 },
576 ),
577 ],
578 ),
579 if (widget.sendViewModel.isMwebAvailable &&
580 widget.mode == SendPageModes.normal)
581 StandardCheckbox(
582 caption: S.of(context).litecoin_mweb_allow_coins,
583 captionColor: Theme.of(context).colorScheme.onSurface,
584 borderColor: Theme.of(context).colorScheme.primary,
585 iconColor: Theme.of(context).colorScheme.primary,
586 value: [UnspentCoinType.any, UnspentCoinType.mweb]
587 .contains(widget.sendViewModel.coinTypeToSpendFrom),
588 onChanged: (value) =>
589 widget.sendViewModel.setAllowMwebCoins(value),
590 ),
591 if (widget.sendViewModel.hasMemos)
592 Observer(
593 builder: (_) => NewSendMemoInput(
594 memoController: _memoControllers[_selectedOutput],
595 maxMemoLength: widget.sendViewModel.maxMemoLength,
596 memoLength: output.memo.length,
597 ),
598 ),
599 if (widget.sendViewModel.hasCoinControl ||
600 widget.sendViewModel.hasFees)
601 AnimatedDropdown(
602 dropdownText: S.of(context).advanced_settings,
603 content: Column(
604 children: [
605 if (widget.sendViewModel.hasFees)
606 ListItemRegularRowWidget(
607 keyValue: "",
608 label: S.of(context).fees,
609 subtitle:
610 "~${output.estimatedFee} ${widget.sendViewModel.currencySymbol} (${output.estimatedFeeFiatAmount} ${widget.sendViewModel.fiatCurrency})",
611 // Without fee priorities the row does nothing,
612 // so it must not be announced as interactive.
613 onTap: widget.sendViewModel.feesViewModel
614 .hasFeesPriority
615 ? () =>
616 pickTransactionPriority(context, output)
617 : null,
618 ),
619 if (widget.sendViewModel.hasCoinControl)
620 ListItemRegularRowWidget(
621 keyValue: "",
622 label: S.of(context).coin_control,
623 onTap: () {
624 showCupertinoModalBottomSheet(
625 enableDrag: false,
626 useRootNavigator: true,
627 isDismissible: false,
628 context: context,
629 builder: (context) => NewCoinControlPage(
630 unspentCoinsListViewModel: widget
631 .sendViewModel
632 .unspentCoinsListViewModel,
633 canEdit: true,
634 ),
635 );
636 },
637 ),
638 ],
639 ),
640 ),
641 ],
642 ),
643 ),
644 ),
645 Observer(
646 builder: (_) => Column(
647 spacing: 12,
648 children: [
649 if (!widget.sendViewModel.isReadyForSend)
650 SendSyncingIndicator(
651 status: widget.sendViewModel.wallet.syncStatus,
652 ),
653 if (widget.sendViewModel.outputs.length > 1)
654 RecipientDotRow(
655 numDots: widget.sendViewModel.outputs.length,
656 onSelected: _setOutput,
657 selectedDot: _selectedOutput,
658 ),
659 Observer(
660 builder: (_) => NewFuturePrimaryButton(
661 key: const ValueKey("send_page_send_button_key"),
662 onPressed: () async {
663 //Request dummy node to get the focus out of the text fields
664 FocusScope.of(context).requestFocus(FocusNode());
665
666 if (widget.sendViewModel.state is IsExecutingState) {
667 return;
668 }
669
670 if (widget.mode == SendPageModes.normal) {
671 await _handleSend();
672 } else if (widget.mode ==
673 SendPageModes.lightningDeposit ||
674 widget.mode == SendPageModes.mwebDeposit) {
675 await Navigator.of(context).push(
676 CupertinoPageRoute(
677 builder: (context) => Material(
678 child: L2ActionWalletSelector(
679 showOtherWallets: false,
680 action: L2Actions.deposit,
681 sendViewModel: widget.sendViewModel,
682 contactListViewModel:
683 widget.contactListViewModel,
684 walletSwitcherViewModel:
685 widget.walletSwitcherViewModel,
686 onSendInitiated: _handleSend,
687 ),
688 ),
689 ),
690 );
691 } else if (widget.mode ==
692 SendPageModes.lightningWithdrawal ||
693 widget.mode == SendPageModes.mwebWithdrawal) {
694 await Navigator.of(context).push(
695 CupertinoPageRoute(
696 builder: (context) => Material(
697 child: L2ActionWalletSelector(
698 showOtherWallets: false,
699 action: L2Actions.withdraw,
700 sendViewModel: widget.sendViewModel,
701 contactListViewModel:
702 widget.contactListViewModel,
703 walletSwitcherViewModel:
704 widget.walletSwitcherViewModel,
705 onSendInitiated: _handleSend,
706 ),
707 ),
708 ),
709 );
710 }
711 },
712 text: S.of(context).continue_text,
713 color: Theme.of(context).colorScheme.primary,
714 textColor: Theme.of(context).colorScheme.onPrimary,
715 disabled: !widget.sendViewModel.isReadyForSend ||
716 widget.sendViewModel.state is ExecutedSuccessfullyState,
717 ),
718 ),
719 const SizedBox(),
720 ],
721 ),
722 ),
723 ],
724 ),
725 ),
726 ),
727 ],
728 ),
729 ),
730 ),
731 ),
732 );
733 },
734 );
735
736 void _onFiatSwitchPressed() {
737 final output = widget.sendViewModel.outputs[_selectedOutput];
738 widget.sendViewModel.outputs[_selectedOutput].isFiatEntry = !output.isFiatEntry;
739
740 final amount = output.isFiatEntry ? output.fiatAmount : output.displayCryptoAmount;
741 _amountControllers[_selectedOutput].text = amount.startsWith("<") ? "0" : amount;
742 }
743
744 void _setOutput(int index) {
745 setState(() {
746 _selectedOutput = index;
747 });
748 // final output = widget.sendViewModel.outputs[index];
749 // _amountController.text = _fiatInputMode ? output.fiatAmount : output.cryptoAmount;
750 // _addressController.text = output.address;
751 }
752
753 void _addInputControllers() {
754 _amountControllers.add(TextEditingController());
755 _addressControllers.add(TextEditingController());
756 _memoControllers.add(TextEditingController());
757
758 _amountControllers[_amountControllers.length - 1].addListener(() {
759 if (_selectedOutput > widget.sendViewModel.outputs.length - 1) {
760 printV(
761 "_selectedOutput > widget.sendViewModel.outputs.length - 1! this should NOT happen!",
762 );
763 return;
764 }
765
766 final amount = _amountControllers[_selectedOutput].text.sanitized();
767 final output = widget.sendViewModel.outputs[_selectedOutput];
768
769 if (output.isFiatEntry) {
770 if (amount != output.fiatAmount) {
771 output.sendAll = false;
772 output.setFiatAmount(amount);
773 }
774 } else {
775 final isAll = mounted && amount != S.of(context).all;
776 if (output.sendAll && isAll) {
777 output.sendAll = false;
778 }
779
780 if (S.current.all.contains(amount)) {
781 return;
782 }
783
784 final cAmount = widget.sendViewModel.amountParsingProxy.getDisplayCryptoAmount(
785 output.cryptoAmount,
786 widget.sendViewModel.selectedCryptoCurrency,
787 );
788 if (amount != cAmount) {
789 final newAmount = widget.sendViewModel.amountParsingProxy
790 .getCanonicalCryptoAmount(amount, widget.sendViewModel.selectedCryptoCurrency);
791 output.setCryptoAmount(newAmount);
792 }
793 }
794 });
795
796 _addressControllers[_amountControllers.length - 1].addListener(() {
797 if (_selectedOutput > widget.sendViewModel.outputs.length - 1) {
798 printV(
799 "_selectedOutput > widget.sendViewModel.outputs.length - 1! this should NOT happen!",
800 );
801 return;
802 }
803
804 final address = _addressControllers[_selectedOutput].text;
805 final output = widget.sendViewModel.outputs[_selectedOutput];
806
807 if (output.address != address && output.extractedAddress != address) {
808 output.resetParsedAddress();
809 output.address = address;
810 }
811 });
812
813 _memoControllers[_amountControllers.length - 1].addListener(() {
814 if (_selectedOutput > widget.sendViewModel.outputs.length - 1) {
815 printV(
816 "_selectedOutput > widget.sendViewModel.outputs.length - 1! this should NOT happen!",
817 );
818 return;
819 }
820 final memo = _memoControllers[_selectedOutput].text;
821 final output = widget.sendViewModel.outputs[_selectedOutput];
822
823 if (memo != output.memo && memo.length <= widget.sendViewModel.maxMemoLength) {
824 output.memo = memo;
825 }
826 });
827 }
828
829 Future<void> _handleSend() async {
830 //TODO(malik1004x): refactor this action. code was copied over from old ui. i don't like it.
831
832 for (var i = 0; i < widget.sendViewModel.outputs.length; i++) {
833 if (i < _amountControllers.length && !widget.sendViewModel.outputs[i].sendAll) {
834 if (widget.sendViewModel.outputs[i].isFiatEntry) {
835 widget.sendViewModel.outputs[i].setFiatAmount(_amountControllers[i].text);
836 } else {
837 final amount = widget.sendViewModel.amountParsingProxy.getCanonicalCryptoAmount(
838 _amountControllers[i].text.sanitized(),
839 widget.sendViewModel.selectedCryptoCurrency,
840 );
841 widget.sendViewModel.outputs[i].setCryptoAmount(amount);
842 }
843 }
844 }
845
846 if (_formKey.currentState != null && !_formKey.currentState!.validate()) {
847 if (widget.sendViewModel.outputs.length > 1) {
848 showErrorValidationAlert(context);
849 }
850
851 return;
852 }
853
854 final notValidItems = widget.sendViewModel.outputs
855 .where((item) => item.address.isEmpty || (!item.sendAll && item.cryptoAmount.isEmpty))
856 .toList();
857
858 if (notValidItems.isNotEmpty) {
859 showErrorValidationAlert(context);
860 return;
861 }
862
863 if (widget.sendViewModel.wallet.isHardwareWallet) {
864 if (!widget.sendViewModel.hardwareWalletViewModel!
865 .isConnected(widget.sendViewModel.walletType)) {
866 await Navigator.of(context).pushNamed(
867 Routes.connectDevices,
868 arguments: ConnectDevicePageParams(
869 walletType: widget.sendViewModel.walletType,
870 hardwareWalletType: widget.sendViewModel.wallet.walletInfo.hardwareWalletType!,
871 onConnectDevice: (_, __) {
872 widget.sendViewModel.hardwareWalletViewModel!.initWallet(widget.sendViewModel.wallet);
873 Navigator.of(context).pop();
874 },
875 isReconnect: false,
876 ),
877 );
878
879 // Recheck to handle tap-backs
880 if (!widget.sendViewModel.hardwareWalletViewModel!
881 .isConnected(widget.sendViewModel.walletType)) {
882 return;
883 }
884 } else {
885 await widget.sendViewModel.hardwareWalletViewModel!.initWallet(widget.sendViewModel.wallet);
886 }
887 }
888
889 if (widget.sendViewModel.wallet.type == WalletType.monero) {
890 var amount = Money.zero(widget.sendViewModel.wallet.currency);
891 for (final item in widget.sendViewModel.outputs) {
892 amount += item.cryptoAmountMoney;
893 }
894 if (monero!.hasUnknownKeyImages(widget.sendViewModel.wallet) && mounted) {
895 await Navigator.of(context).pushNamed(
896 Routes.syncKeyImagesDevices,
897 arguments: monero!.exportOutputsUR(widget.sendViewModel.wallet),
898 );
899 await Future.delayed(const Duration(seconds: 1)); // wait for monero to refresh the state
900 }
901 if (monero!.needExportOutputs(widget.sendViewModel.wallet, amount)) {
902 return;
903 }
904 }
905
906 final check = widget.sendViewModel.shouldDisplayTotp();
907 widget.authService.authenticateAction(
908 navigatorKey.currentContext ?? context,
909 conditionToDetermineIfToUse2FA: check,
910 onAuthSuccess: (value) async {
911 if (value) {
912 if (!widget.mode.popOnConfirmation && Navigator.canPop(context)) {
913 Navigator.of(context, rootNavigator: true).pop();
914 }
915 showModalBottomSheet(
916 isScrollControlled: true,
917 isDismissible: false,
918 enableDrag: false,
919 context: navigatorKey.currentContext ?? context,
920 backgroundColor: Colors.transparent,
921 builder: (context) => SendConfirmSheet(
922 title: widget.mode.title,
923 iconPath: widget.mode.helpContent?.imagePath,
924 sendViewModel: widget.sendViewModel,
925 ),
926 ).then((value) {
927 if (widget.sendViewModel.state is TransactionCommitted &&
928 widget.mode.popOnConfirmation) {
929 if (!mounted) {
930 return;
931 }
932 Navigator.of(context, rootNavigator: true).pop();
933 }
934 widget.sendViewModel.dismissTransaction();
935 });
936
937 await widget.sendViewModel.createTransaction();
938 }
939 },
940 );
941 }
942
943 void _removeInputControllers(int index) {
944 _amountControllers.removeAt(index);
945 _addressControllers.removeAt(index);
946 _memoControllers.removeAt(index);
947 }
948
949 void _presentCurrencyPicker(BuildContext context) {
950 final output = widget.sendViewModel.outputs[_selectedOutput];
951
952 if (!output.isFiatEntry && !widget.sendViewModel.hasMultipleTokens) {
953 return;
954 }
955
956 if (output.isFiatEntry) {
957 FiatCurrencyPickerSheet.show(
958 context: context,
959 selected: widget.sendViewModel.fiatCurrency,
960 onSelected: (cur) async {
961 widget.sendViewModel.setFiatCurrency(cur);
962 await output.calculateEstimatedFee();
963 },
964 );
965 return;
966 }
967
968 final isFiatDisabled = widget.sendViewModel.isFiatDisabled;
969 final balanceByAsset = <CryptoCurrency, CurrencyPickerBalance>{
970 for (final r in widget.sendViewModel.balanceViewModel.formattedBalances)
971 r.asset: CurrencyPickerBalance(
972 amount: "${r.availableBalance} ${r.asset.title}",
973 fiat: isFiatDisabled ? null : "${r.fiatAvailableBalanceRaw} ${r.fiatCurrency?.symbol}",
974 fiatValue: isFiatDisabled ? null : double.tryParse(r.fiatAvailableBalanceRaw),
975 ),
976 };
977
978 CurrencyPickerSheet.show(
979 context: context,
980 args: CurrencyPickerArgs(
981 items: widget.sendViewModel.currencies,
982 selected: widget.sendViewModel.selectedCryptoCurrency,
983 filterByNetwork: widget.sendViewModel.walletType,
984 balanceByAsset: balanceByAsset,
985 useSingleNetworkLayout: true,
986 symbolResolver: widget.sendViewModel.amountParsingProxy.getCryptoSymbol,
987 onSelected: (currency) {
988 widget.sendViewModel.selectedCryptoCurrency = currency;
989 output.calculateEstimatedFee();
990 },
991 ),
992 );
993 }
994
995 void _handleLightningInvoicePaste() {
996 try {
997 final lnAmount = getBolt11Amount(_addressControllers[_selectedOutput].text) ??
998 Money.zero(CryptoCurrency.btcln);
999 if (!lnAmount.isZero) {
1000 _amountControllers[_selectedOutput].text =
1001 widget.sendViewModel.amountParsingProxy.asDisplayString(lnAmount);
1002 }
1003 } catch (_) {}
1004 }
1005
1006 static final RegExp _evmAddressRegExp = RegExp(r"^0x[0-9a-fA-F]{40}$");
1007
1008 bool _hasEvmRecipient(Output output) {
1009 final address = output.isParsedAddress ? output.extractedAddress : output.address;
1010 return _evmAddressRegExp.hasMatch(address.trim());
1011 }
1012
1013 Future<void> _presentRecipientNetworkPicker(ChainInfo currentChain) async {
1014 if (evm == null) {
1015 return;
1016 }
1017
1018 final chains = evm!.getAllChains();
1019 final networks = chains.map((chain) {
1020 final walletType =
1021 evm!.getWalletTypeByChainId(chain.chainId) ?? widget.sendViewModel.wallet.type;
1022 return RecipientNetworkItem(
1023 chainId: chain.chainId,
1024 name: chain.name,
1025 iconPath: symbolIconPathForWalletType(walletType) ?? "",
1026 );
1027 }).toList();
1028
1029 final selectedChainId = await SelectRecipientNetworkSheet.show(
1030 context: context,
1031 networks: networks,
1032 currentChainId: currentChain.chainId,
1033 );
1034
1035 if (selectedChainId == null || selectedChainId == currentChain.chainId) {
1036 return;
1037 }
1038
1039 final target = chains.firstWhere(
1040 (chain) => chain.chainId == selectedChainId,
1041 orElse: () => currentChain,
1042 );
1043 if (target.chainId == currentChain.chainId) {
1044 return;
1045 }
1046
1047 await _handleManualNetworkSelection(target);
1048 }
1049
1050 Future<void> _handleManualNetworkSelection(ChainInfo target) async {
1051 final output = widget.sendViewModel.outputs[_selectedOutput];
1052 final address = output.isParsedAddress
1053 ? output.extractedAddress
1054 : _addressControllers[_selectedOutput].text.trim();
1055 final note = _memoControllers[_selectedOutput].text;
1056 final isTokenSelected = widget.sendViewModel.selectedCryptoCurrency is Erc20Token;
1057 final amount = isTokenSelected ? "" : _amountControllers[_selectedOutput].text;
1058 final request =
1059 AnyPayParser.fromPaymentRequest(PaymentRequest(address, amount, note, "", null));
1060
1061 final evaluation = await widget.anyPayService.evaluateForEvmChain(request, target.chainId);
1062 if (!mounted) {
1063 return;
1064 }
1065 await _handleEvaluation(evaluation, fallbackCurrency: target.currency);
1066 }
1067
1068 int _currentEvmChainIdOrMainnet() {
1069 final wallet = widget.sendViewModel.wallet;
1070 if (isEVMCompatibleChain(wallet.type)) {
1071 return evm!.getSelectedChainId(wallet) ?? evm!.getChainIdByWalletType(wallet.type);
1072 }
1073 return 1;
1074 }
1075
1076 Future<bool> _handleIfOpenCryptoPay(String input) async {
1077 if (!OpenCryptoPayService.isOpenCryptoPayQR(input) ||
1078 widget.sendViewModel.selectedCryptoCurrency == CryptoCurrency.btcln) {
1079 return false;
1080 }
1081
1082 if (_selectedOutput != 0) {
1083 setState(() => _selectedOutput = 0);
1084 }
1085
1086 final request = await widget.sendViewModel.getOpenCryptoPayRequest(input);
1087 if (request != null && mounted) {
1088 _applyPaymentRequest(request);
1089 }
1090 return true;
1091 }
1092
1093 Future<void> _runAnyPayFlow({String? rawInput, PaymentRequest? paymentRequest}) async {
1094 assert(rawInput != null || paymentRequest != null);
1095
1096 final evaluation = rawInput != null
1097 ? await widget.anyPayService.evaluateRawInput(rawInput)
1098 : await widget.anyPayService.evaluatePaymentRequest(paymentRequest!);
1099
1100 if (!mounted) {
1101 return;
1102 }
1103
1104 if (await _handleIfOpenCryptoPay(evaluation.request.rawInput)) {
1105 return;
1106 }
1107
1108 await _handleEvaluation(evaluation);
1109 }
1110
1111 Future<void> _runAnyPayPaste(String pasted) async {
1112 if (await _handleIfOpenCryptoPay(pasted)) {
1113 return;
1114 }
1115
1116 final parsed = PaymentRequest.fromString(pasted);
1117 final parsedAmount = parsed.amount == "0" ? "" : parsed.amount;
1118 final merged = parsed.copyWith(
1119 amount: parsedAmount.isNotEmpty ? parsedAmount : _amountControllers[_selectedOutput].text,
1120 note: parsed.note.isNotEmpty ? parsed.note : _memoControllers[_selectedOutput].text,
1121 );
1122
1123 await _runAnyPayFlow(paymentRequest: merged);
1124 }
1125
1126 Future<void> _handleEvaluation(
1127 AnyPayEvaluation evaluation, {
1128 CryptoCurrency? fallbackCurrency,
1129 }) async {
1130 final evaluationResult = await _anyPayFlow.handleEvaluation(
1131 context,
1132 evaluation,
1133 fallbackCurrency: fallbackCurrency,
1134 );
1135 if (evaluationResult == null || !mounted) {
1136 return;
1137 }
1138
1139 try {
1140 await _applyIntent(
1141 evaluationResult.request,
1142 token: evaluationResult.token,
1143 fallbackCurrency: evaluationResult.fallbackCurrency,
1144 amountOverride: evaluationResult.amountOverride,
1145 );
1146 } catch (e) {
1147 printV("Payment flow error: $e");
1148 _applyPaymentRequest(evaluationResult.request.paymentRequest);
1149 }
1150 }
1151
1152 Future<void> _applyIntent(
1153 AnyPayRequest request, {
1154 CryptoCurrency? token,
1155 CryptoCurrency? fallbackCurrency,
1156 String? amountOverride,
1157 }) async {
1158 final currency = token ?? fallbackCurrency;
1159 if (currency != null) {
1160 widget.sendViewModel.applyAnyPayCurrency(currency);
1161 }
1162
1163 if (token == null && fallbackCurrency != null && request.amount.isEmpty) {
1164 widget.sendViewModel.outputs[_selectedOutput].setCryptoAmount("");
1165 _amountControllers[_selectedOutput].clear();
1166 }
1167
1168 _applyPaymentRequest(request.paymentRequest, amountOverride: amountOverride);
1169 }
1170
1171 void _applyPaymentRequest(PaymentRequest paymentRequest, {String? amountOverride}) {
1172 if (widget.sendViewModel.usePayjoin) {
1173 widget.sendViewModel.payjoinUri = paymentRequest.pjUri;
1174 }
1175 _addressControllers[_selectedOutput].text = paymentRequest.address;
1176 final amountToApply = amountOverride ?? paymentRequest.amount;
1177 if (amountToApply.isNotEmpty) {
1178 try {
1179 _amountControllers[_selectedOutput].text =
1180 widget.sendViewModel.amountParsingProxy.getDisplayCryptoAmount(
1181 amountToApply,
1182 widget.sendViewModel.selectedCryptoCurrency,
1183 );
1184 } catch (e) {
1185 printV('applyPaymentRequest: failed to parse amount "$amountToApply": $e');
1186 }
1187 }
1188 // _memoControllers[_selectedOutput].text = paymentRequest.note;
1189
1190 _applyNote(paymentRequest.note, _selectedOutput);
1191 }
1192
1193 void showErrorValidationAlert(BuildContext context) {
1194 int emptyAddressIndex = -1;
1195 for (int i = 0; i < widget.sendViewModel.outputs.length; i++) {
1196 if (widget.sendViewModel.outputs[i].address.isEmpty) {
1197 emptyAddressIndex = i;
1198 break;
1199 }
1200 }
1201
1202 showPopUp<void>(
1203 context: context,
1204 builder: (context) => AlertWithOneAction(
1205 alertTitle: S.of(context).error,
1206 alertContent: emptyAddressIndex == -1
1207 ? S.of(context).check_receiver_forms
1208 : S.of(context).enter_recipient_address,
1209 buttonText: S.of(context).ok,
1210 buttonAction: () => Navigator.of(context).pop(),
1211 ),
1212 );
1213 if (emptyAddressIndex != -1) {
1214 _setOutput(emptyAddressIndex);
1215 }
1216 }
1217
1218 bool isRegularElectrumAddress(String address) {
1219 final supportedTypes = [CryptoCurrency.btc, CryptoCurrency.ltc, CryptoCurrency.bch];
1220 final excludedPatterns = [
1221 RegExp(AddressValidator.silentPaymentAddressPatternMainnet),
1222 RegExp(AddressValidator.silentPaymentAddressPatternTestnet),
1223 RegExp(AddressValidator.mWebAddressPattern),
1224 RegExp(AddressValidator.bolt11InvoiceMatcher),
1225 ];
1226
1227 final trimmed = address.trim();
1228
1229 bool isValid = false;
1230 for (final type in supportedTypes) {
1231 final addressPattern = AddressValidator.getAddressFromStringPattern(type);
1232 if (addressPattern != null) {
1233 final regex = RegExp("^$addressPattern\$");
1234 if (regex.hasMatch(trimmed)) {
1235 isValid = true;
1236 break;
1237 }
1238 }
1239 }
1240
1241 for (final pattern in excludedPatterns) {
1242 if (pattern.hasMatch(trimmed)) {
1243 return false;
1244 }
1245 }
1246
1247 return isValid;
1248 }
1249
1250 Future<void> pickTransactionPriority(BuildContext pageContext, Output output) async {
1251 final items = priorityForWalletType(widget.sendViewModel.walletType);
1252 final selectedItem = items.indexOf(widget.sendViewModel.feesViewModel.transactionPriority);
1253 final customItemIndex = widget.sendViewModel.feesViewModel.getCustomPriorityIndex(items);
1254 final isBitcoinWallet = widget.sendViewModel.walletType == WalletType.bitcoin;
1255 final maxCustomFeeRate = widget.sendViewModel.feesViewModel.maxCustomFeeRate?.toDouble();
1256
1257 FocusManager.instance.primaryFocus?.unfocus();
1258
1259 await showCupertinoModalBottomSheet(
1260 context: pageContext,
1261 expand: false,
1262 builder: (modalContext) {
1263 int selectedIdx = selectedItem;
1264 return Observer(
1265 builder: (context) {
1266 final customFeeRate = isBitcoinWallet
1267 ? widget.sendViewModel.feesViewModel.customBitcoinFeeRate.toDouble()
1268 : null;
1269 return StatefulBuilder(
1270 builder: (context, setState) => IntrinsicHeight(
1271 // height: MediaQuery.of(context).size.height*0.4,
1272 child: ModalNavigator(
1273 parentContext: modalContext,
1274 heightMode: ModalHeightModes.natural,
1275 rootPage: Material(
1276 child: NewPicker(
1277 title: S.of(context).set_fees,
1278 description: S.of(context).set_fees_desc,
1279 sliderPageTitle: S.of(context).custom_fee,
1280 sliderInitialValue: customFeeRate,
1281 sliderMaxValue: maxCustomFeeRate,
1282 sliderValueDescription: "sat/byte",
1283 items: items
1284 .map(
1285 (item) => PickerItem<TransactionPriority>(
1286 title: item.title,
1287 subtitle: item.description,
1288 hint: item.hint,
1289 value: item,
1290 isSliderItem: items.indexOf(item) == customItemIndex,
1291 ),
1292 )
1293 .toList(),
1294 onItemSelected: (priority) async {
1295 widget.sendViewModel.feesViewModel.setTransactionPriority(priority);
1296 setState(() => selectedIdx = items.indexOf(priority));
1297 await output.calculateEstimatedFee();
1298 },
1299 onSliderChanged: (value) {
1300 widget.sendViewModel.feesViewModel.customBitcoinFeeRate = value.round();
1301 },
1302 selectedIndex: selectedIdx,
1303 ),
1304 ),
1305 ),
1306 ),
1307 );
1308 },
1309 );
1310 },
1311 );
1312 }
1313
1314 String _wrapAmount(String amount, int maxChars) =>
1315 amount.length <= maxChars ? amount : "${amount.substring(0, maxChars - 3)}...";
1316
1317 // TODO: make a separate variable for memo in payment request model
1318 void _applyNote(String note, int selectedOutput) {
1319 if (widget.sendViewModel.hasMemos && note.length <= widget.sendViewModel.maxMemoLength) {
1320 widget.sendViewModel.outputs[selectedOutput].memo = note;
1321 } else {
1322 widget.sendViewModel.outputs[selectedOutput].note = note;
1323 }
1324 }
1325 }
1326
1327 class SendHelpPage extends StatelessWidget {
1328 const SendHelpPage({required this.content, super.key});
1329
1330 final SendPageHelpContent content;
1331
1332 @override
1333 Widget build(BuildContext context) => SafeArea(
1334 child: Column(
1335 mainAxisSize: MainAxisSize.max,
1336 mainAxisAlignment: MainAxisAlignment.spaceBetween,
1337 children: [
1338 ModalTopBar(
1339 title: content.title,
1340 leadingIcon: const Icon(Icons.arrow_back_ios_new),
1341 leadingSemanticLabel: S.of(context).seed_alert_back,
1342 onLeadingPressed: Navigator.of(context).pop,
1343 ),
1344 Padding(
1345 padding: const EdgeInsets.symmetric(horizontal: 18),
1346 child: Column(
1347 spacing: 12,
1348 mainAxisSize: MainAxisSize.max,
1349 mainAxisAlignment: MainAxisAlignment.center,
1350 children: [
1351 CakeImageWidget(imageUrl: content.imagePath),
1352 Text(
1353 content.description,
1354 textAlign: TextAlign.center,
1355 style: TextStyle(
1356 fontSize: 14,
1357 fontWeight: FontWeight.w400,
1358 color: Theme.of(context).colorScheme.onSurface,
1359 ),
1360 ),
1361 if (content.disclaimer != null) ...[
1362 const SizedBox(),
1363 const SizedBox(),
1364 Text(
1365 content.disclaimer!,
1366 textAlign: TextAlign.center,
1367 style: TextStyle(
1368 fontSize: 14,
1369 fontWeight: FontWeight.w400,
1370 color: Theme.of(context).colorScheme.onSurfaceVariant,
1371 ),
1372 ),
1373 ],
1374 ],
1375 ),
1376 ),
1377 Padding(
1378 padding: const EdgeInsets.symmetric(horizontal: 18),
1379 child: NewPrimaryButton(
1380 onPressed: Navigator.of(context).pop,
1381 text: S.of(context).i_understand,
1382 color: Theme.of(context).colorScheme.primary,
1383 textColor: Theme.of(context).colorScheme.onPrimary,
1384 ),
1385 ),
1386 ],
1387 ),
1388 );
1389 }
1390
1391 Future<bool> showParsedAddressConfirmationAlert(
1392 BuildContext context,
1393 ParsedAddress parsedAddress,
1394 ) async {
1395 final confirmed = await showPopUp<bool>(
1396 context: context,
1397 builder: (context) => AlertWithOneAction(
1398 alertTitle: S.of(context).address_detected,
1399 headerTitleText: parsedAddress.profileName.isEmpty ? null : parsedAddress.profileName,
1400 headerImageProfileUrl: parsedAddress.profileImageUrl.isEmpty
1401 ? parsedAddress.addressSource.iconPath
1402 : parsedAddress.profileImageUrl,
1403 alertContent: S.of(context).extracted_address_content(
1404 "${parsedAddress.handle} (${parsedAddress.addressSource.label})",
1405 ),
1406 buttonText: S.of(context).ok,
1407 buttonAction: () => Navigator.of(context).pop(true),
1408 ),
1409 );
1410
1411 return confirmed ?? false;
1412 }