dev
dart 725 lines 29.5 KB
Raw
1 import 'dart:io';
2
3 import 'package:cake_wallet/core/address_validator.dart';
4 import 'package:cake_wallet/core/utilities.dart';
5 import 'package:cake_wallet/di.dart';
6 import 'package:cake_wallet/entities/contact.dart';
7 import 'package:cake_wallet/entities/contact_record.dart';
8 import 'package:cake_wallet/generated/i18n.dart';
9 import 'package:cake_wallet/new-ui/widgets/animated_dropdown.dart';
10 import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart';
11 import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart';
12 import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
13 import 'package:cake_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart';
14 import 'package:cake_wallet/routes.dart';
15 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
16 import 'package:cake_wallet/utils/address_formatter.dart';
17 import 'package:cake_wallet/view_model/send/send_view_model.dart';
18 import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
19 import 'package:cake_wallet/bitcoin/bitcoin.dart';
20 import 'package:cw_core/amount/money.dart';
21 import 'package:cw_core/cake_hive.dart';
22 import 'package:cw_core/crypto_amount_format.dart';
23 import 'package:cw_core/crypto_currency.dart';
24 import 'package:flutter/cupertino.dart';
25 import 'package:flutter/material.dart';
26 import 'package:flutter_mobx/flutter_mobx.dart';
27 import 'package:mobx/mobx.dart';
28
29 class SendConfirmSheet extends StatefulWidget {
30 const SendConfirmSheet(
31 {super.key, required this.sendViewModel, this.isPage = false, this.title, this.iconPath});
32
33 final SendViewModel sendViewModel;
34 final bool isPage;
35 final String? title;
36 final String? iconPath;
37
38 @override
39 State<SendConfirmSheet> createState() => _SendConfirmSheetState();
40 }
41
42 class _SendConfirmSheetState extends State<SendConfirmSheet> {
43 bool _committed = false;
44
45 void initState() {
46 super.initState();
47 reaction((_) => widget.sendViewModel.state, (state) {
48 if (state is TransactionCommitted) {
49 setState(() {
50 _committed = true;
51 });
52 }
53 });
54 }
55
56 @override
57 Widget build(BuildContext context) {
58 return PopScope(
59 canPop: !widget.isPage,
60 onPopInvokedWithResult: (didPop, result) {
61 if (widget.isPage) {
62 Navigator.of(context, rootNavigator: true).pop();
63 }
64 },
65 child: SafeArea(
66 bottom: false,
67 minimum: widget.isPage ? EdgeInsets.zero : EdgeInsets.only(top: 64),
68 child: Container(
69 decoration: BoxDecoration(
70 color: Theme.of(context).colorScheme.surface,
71 borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
72 ),
73 child: SafeArea(child: Observer(
74 builder: (_) {
75 return AnimatedSize(
76 duration: const Duration(milliseconds: 300),
77 curve: Curves.easeOutCubic,
78 alignment: Alignment.topCenter,
79 clipBehavior: Clip.hardEdge,
80 child: Stack(
81 clipBehavior: Clip.none,
82 children: [
83 Align(
84 alignment: Alignment.topCenter,
85 heightFactor: _committed ? 0.0 : 1.0,
86 child: AnimatedSlide(
87 offset: _committed ? const Offset(-1, 0) : Offset.zero,
88 duration: const Duration(milliseconds: 300),
89 curve: Curves.easeOutCubic,
90 // Both screens stay mounted; only the visible one may be reachable.
91 child: ExcludeSemantics(
92 excluding: _committed,
93 child: SendTransactionDetails(
94 sendViewModel: widget.sendViewModel,
95 isPage: widget.isPage,
96 title: widget.title,
97 iconPath: widget.iconPath,
98 ),
99 ),
100 ),
101 ),
102 Align(
103 alignment: Alignment.topCenter,
104 heightFactor: _committed ? 1.0 : 0.0,
105 child: AnimatedSlide(
106 offset: _committed ? Offset.zero : const Offset(1, 0),
107 duration: const Duration(milliseconds: 300),
108 curve: Curves.easeOutCubic,
109 child: ExcludeSemantics(
110 excluding: !_committed,
111 child: TransactionCommitedScreen(
112 sendViewModel: widget.sendViewModel,
113 ),
114 ),
115 ),
116 ),
117 ],
118 ),
119 );
120 },
121 )),
122 ),
123 ),
124 );
125 }
126 }
127
128 class SendTransactionDetails extends StatelessWidget {
129 const SendTransactionDetails(
130 {super.key, required this.sendViewModel, required this.isPage, this.title, this.iconPath});
131
132 final SendViewModel sendViewModel;
133 final bool isPage;
134 final String? title;
135 final String? iconPath;
136
137 @override
138 Widget build(BuildContext context) {
139 final resolvedIconPath = iconPath ?? sendViewModel.currency.iconPath ?? "";
140
141 return LayoutBuilder(
142 builder: (context, constraints) {
143 return Column(
144 key: ValueKey(0),
145 mainAxisSize: isPage ? MainAxisSize.max : MainAxisSize.min,
146 children: [
147 ModalTopBar(
148 title: "",
149 leadingWidget: Row(
150 spacing: 8,
151 children: [
152 if (resolvedIconPath.toLowerCase().endsWith(".svg"))
153 CakeImageWidget(
154 imageUrl: resolvedIconPath,
155 width: 28,
156 height: 28,
157 )
158 else
159 Image.asset(
160 resolvedIconPath,
161 width: 28,
162 height: 28,
163 ),
164 Semantics(
165 header: true,
166 // Android reads the heading from headingLevel since the
167 // Flutter 3.41 engine; header: alone only covers iOS.
168 headingLevel: 1,
169 child: Text(
170 title ?? S.of(context).send,
171 style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20),
172 ),
173 )
174 ],
175 ),
176 trailingIcon: Icon(Icons.close),
177 trailingSemanticLabel: S.of(context).close,
178 onTrailingPressed: Navigator.of(context).maybePop,
179 ),
180 isPage
181 ? Expanded(child: _buildMainContent(context))
182 : Flexible(child: _buildMainContent(context))
183 ]);
184 },
185 );
186 }
187
188 double sumBy<T>(List<T> list, double Function(T) picker) =>
189 list.map(picker).fold(0.0, (a, b) => a + b);
190
191 Money sumByMoney<T>(List<T> list, Money Function(T) picker, CryptoCurrency currency) =>
192 list.map(picker).fold(Money.zero(currency), (a, b) => a + b);
193
194 String sumStr<T>(List<T> list, double Function(T) picker) => sumBy(list, picker).toString();
195
196 String sumWithUnit<T>(List<T> list, double Function(T) picker, String unit, {int? decimals}) {
197 final str = sumStr(list, picker);
198 return "${decimals == null ? str : str.withDecimals(decimals)} $unit";
199 }
200
201 Widget _buildMainContent(BuildContext context) {
202 return Observer(builder: (context) {
203 final transaction = sendViewModel.pendingTransaction;
204 final additionalCostNotice = sendViewModel.pendingTransactionAdditionalCostNotice;
205
206 final currencySymbol =
207 sendViewModel.amountParsingProxy.getCryptoSymbol(sendViewModel.selectedCryptoCurrency);
208
209 final amount = (transaction == null)
210 ? sendViewModel.amountParsingProxy.asDisplayString(sumByMoney(sendViewModel.outputs, (o) {
211 final zero = Money.zero(sendViewModel.selectedCryptoCurrency);
212 if (o.sendAll)
213 return sendViewModel.amountParsingProxy.tryParseCryptoString(
214 sendViewModel.balance, sendViewModel.selectedCryptoCurrency) ??
215 zero;
216
217 return sendViewModel.selectedCryptoCurrency.tryParseAmount(o.cryptoAmount) ?? zero;
218 }, sendViewModel.selectedCryptoCurrency))
219 : sendViewModel.amountParsingProxy.asDisplayString(transaction.amount);
220
221 final fee = (transaction == null)
222 ? sendViewModel.amountParsingProxy.asDisplayString(sumByMoney(
223 sendViewModel.outputs,
224 (o) => o.estimatedFee,
225 sendViewModel.currency,
226 ))
227 : sendViewModel.amountParsingProxy.asDisplayString(transaction.fee);
228
229 final fiatAmount = (transaction == null)
230 ? sumWithUnit(
231 sendViewModel.outputs,
232 (o) => double.tryParse(o.fiatAmount.replaceAll(",", "")) ?? 0,
233 sendViewModel.fiatCurrency.title,
234 decimals: 2)
235 : sendViewModel.pendingTransactionFiatAmountFormatted;
236
237 final fiatFee = (transaction == null)
238 ? sumWithUnit(
239 sendViewModel.outputs,
240 (o) => double.tryParse(o.estimatedFeeFiatAmount.replaceAll(",", "")) ?? 0,
241 sendViewModel.fiatCurrency.title,
242 decimals: 2)
243 : sendViewModel.pendingTransactionFeeFiatAmountFormatted;
244
245 final showAddress = !sendViewModel.outputs.any((e) =>
246 RegExp(AddressValidator.bolt11InvoiceMatcher).hasMatch(e.address.toLowerCase()) ||
247 RegExp(AddressValidator.lnurlMatcher).hasMatch(e.address.toLowerCase()) ||
248 (e.isParsedAddress &&
249 e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency] !=
250 null &&
251 e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency]!
252 .isNotEmpty &&
253 RegExp(AddressValidator.lnurlMatcher).hasMatch(e
254 .parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency]!
255 .toLowerCase())));
256
257 final outputs = sendViewModel.outputs;
258
259 return SingleChildScrollView(
260 child: Padding(
261 padding: const EdgeInsets.symmetric(horizontal: 24.0),
262 child: Column(
263 crossAxisAlignment: CrossAxisAlignment.start,
264 mainAxisAlignment: MainAxisAlignment.start,
265 spacing: 24,
266 children: [
267 // The amount being sent is the value under review: announce it as one group.
268 MergeSemantics(
269 child: Column(
270 children: [
271 Row(
272 mainAxisSize: MainAxisSize.max,
273 mainAxisAlignment: MainAxisAlignment.center,
274 spacing: 4,
275 children: [
276 Flexible(
277 child: Text(
278 amount,
279 style: TextStyle(
280 fontSize: 36,
281 fontWeight: FontWeight.w400,
282 color: Theme.of(context).colorScheme.onSurface),
283 ),
284 ),
285 Text(currencySymbol,
286 style: TextStyle(
287 fontSize: 36,
288 fontWeight: FontWeight.w400,
289 color: Theme.of(context).colorScheme.onSurfaceVariant))
290 ],
291 ),
292 Text(
293 fiatAmount,
294 style: TextStyle(
295 fontSize: 20,
296 fontWeight: FontWeight.w500,
297 color: Theme.of(context).colorScheme.onSurfaceVariant),
298 ),
299 ],
300 ),
301 ),
302 if (outputs.length >= 1 &&
303 (outputs.first.extractedAddress.isNotEmpty || outputs.first.address.isNotEmpty) &&
304 showAddress)
305 Column(
306 crossAxisAlignment: CrossAxisAlignment.start,
307 spacing: 12,
308 children: [
309 Text(
310 S.of(context).send_to,
311 style: TextStyle(
312 fontSize: 16,
313 fontWeight: FontWeight.w500,
314 color: Theme.of(context).colorScheme.onSurfaceVariant),
315 ),
316 if (outputs.length == 1)
317 Container(
318 decoration: BoxDecoration(
319 color: Theme.of(context).colorScheme.surfaceContainer,
320 borderRadius: BorderRadius.circular(16),
321 ),
322 child: Padding(
323 padding: const EdgeInsets.all(12.0),
324 child: AddressFormatter.buildSegmentedAddress(
325 address: outputs.first.isParsedAddress
326 ? outputs.first.extractedAddress
327 : outputs.first.address,
328 evenTextStyle:
329 TextStyle(color: Theme.of(context).colorScheme.onSurface)),
330 ),
331 )
332 else
333 AnimatedDropdown(
334 content: Column(
335 children: outputs
336 .map(
337 (item) => Column(
338 children: [
339 MultiSendAddressPreview(
340 index: outputs.indexOf(item) + 1,
341 address: item.isParsedAddress
342 ? item.extractedAddress
343 : item.address,
344 amount:
345 "${item.roundedCryptoAmount(8).withLocalSeperator(sendViewModel.languageCode)} ${sendViewModel.currency.title}",
346 fiatAmount:
347 "${item.fiatAmount.withDecimals(2).withLocalSeperator(sendViewModel.languageCode)} ${sendViewModel.fiatCurrency.title}",
348 ),
349 if (item != outputs.last)
350 Container(
351 width: double.infinity,
352 height: 1,
353 color:
354 Theme.of(context).colorScheme.surfaceContainerHigh)
355 ],
356 ),
357 )
358 .toList(),
359 ),
360 dropdownText: "${outputs.length} ${S.of(context).addresses}"),
361 ],
362 ),
363 Container(
364 decoration: BoxDecoration(
365 color: Theme.of(context).colorScheme.surfaceContainer,
366 borderRadius: BorderRadius.circular(16),
367 ),
368 child: Column(
369 children: [
370 Padding(
371 padding: const EdgeInsets.all(12.0),
372 child: MergeSemantics(
373 child: Row(
374 mainAxisAlignment: MainAxisAlignment.spaceBetween,
375 children: [
376 Text(S.of(context).fee,
377 style: TextStyle(
378 fontSize: 14,
379 fontWeight: FontWeight.w400,
380 color: Theme.of(context).colorScheme.onSurface)),
381 Column(
382 crossAxisAlignment: CrossAxisAlignment.end,
383 children: [
384 Text(
385 "${fee.withLocalSeperator(sendViewModel.languageCode)} ${sendViewModel.currencySymbol}",
386 style: TextStyle(
387 fontSize: 14,
388 fontWeight: FontWeight.w400,
389 color: Theme.of(context).colorScheme.onSurfaceVariant),
390 ),
391 Text(fiatFee.withLocalSeperator(sendViewModel.languageCode),
392 style: TextStyle(
393 fontSize: 14,
394 fontWeight: FontWeight.w400,
395 color: Theme.of(context).colorScheme.onSurfaceVariant))
396 ],
397 )
398 ],
399 ),
400 ),
401 ),
402 if (additionalCostNotice != null) ...[
403 Padding(
404 padding: const EdgeInsets.symmetric(horizontal: 12),
405 child: Container(
406 height: 1,
407 color: Theme.of(context).colorScheme.surfaceContainerHigh,
408 ),
409 ),
410 Padding(
411 padding: const EdgeInsets.all(12),
412 child: Text(
413 additionalCostNotice,
414 style: TextStyle(
415 fontSize: 14,
416 fontWeight: FontWeight.w400,
417 color: Theme.of(context).colorScheme.onSurfaceVariant,
418 ),
419 ),
420 ),
421 ],
422 if (sendViewModel.isElectrumWallet) ...[
423 Padding(
424 padding: EdgeInsets.symmetric(horizontal: 12),
425 child: Container(
426 height: 1,
427 color: Theme.of(context).colorScheme.surfaceContainerHigh,
428 ),
429 ),
430 Padding(
431 padding: const EdgeInsets.all(12.0),
432 child: MergeSemantics(
433 child: Row(
434 mainAxisAlignment: MainAxisAlignment.spaceBetween,
435 children: [
436 Text(S.of(context).network,
437 style: TextStyle(
438 fontSize: 14,
439 fontWeight: FontWeight.w400,
440 color: Theme.of(context).colorScheme.onSurface)),
441 Column(
442 children: [
443 Text(
444 sendViewModel.selectedCryptoCurrency == CryptoCurrency.btcln
445 ? "Lightning"
446 : bitcoin!.getNetworkName(sendViewModel.wallet),
447 style: TextStyle(
448 fontSize: 14,
449 fontWeight: FontWeight.w400,
450 color: Theme.of(context).colorScheme.onSurfaceVariant))
451 ],
452 )
453 ],
454 ),
455 ),
456 )
457 ],
458 ],
459 ),
460 ),
461 SendConfirmBottomWidget(sendViewModel: sendViewModel),
462 if (Platform.isAndroid) // spacing between bottom widget and system navbar
463 SizedBox(),
464 ],
465 ),
466 ),
467 );
468 });
469 }
470
471 String formatAmount(String amount) {
472 try {
473 return amount.withMaxDecimals(8);
474 } catch (e) {
475 return amount;
476 }
477 }
478 }
479
480 class TransactionCommitedScreen extends StatefulWidget {
481 const TransactionCommitedScreen({super.key, this.sendViewModel});
482
483 final SendViewModel? sendViewModel;
484
485 @override
486 State<TransactionCommitedScreen> createState() => _TransactionCommitedScreenState();
487 }
488
489 class _TransactionCommitedScreenState extends State<TransactionCommitedScreen> {
490 bool _isNoteButtonLoading = false;
491
492 @override
493 Widget build(BuildContext context) {
494 return Observer(
495 builder: (_) => Column(
496 spacing: 12,
497 mainAxisSize: MainAxisSize.min,
498 mainAxisAlignment: MainAxisAlignment.spaceAround,
499 children: [
500 SizedBox(
501 height: 12,
502 ),
503 // The sheet swaps its content in place, so this title becoming visible is what
504 // tells a screen reader that the transaction went through.
505 Semantics(
506 liveRegion: true,
507 child: Text(
508 S.of(context).transaction_sent_new,
509 style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
510 ),
511 ),
512 SizedBox(),
513 CakeImageWidget(width: 200, height: 200, imageUrl: "assets/new-ui/birthday_cake.svg"),
514 Padding(
515 padding: const EdgeInsets.symmetric(horizontal: 24.0),
516 child: Column(
517 spacing: 12,
518 children: [
519 if (widget.sendViewModel != null)
520 Row(
521 spacing: 8,
522 children: [
523 if (!(widget.sendViewModel!.checkIfAddressIsAContact(
524 widget.sendViewModel!.outputs.first.address)) &&
525 !(widget.sendViewModel!.outputs.first.isParsedAddress))
526 TransactionCommittedScreenActionButton(
527 text: S.of(context).save_contact,
528 iconPath: "assets/new-ui/save_contact.svg",
529 onTap: () {
530 Navigator.of(context).pushNamed(Routes.addressBookAddContact,
531 arguments: ContactRecord(
532 CakeHive.box<Contact>(Contact.boxName),
533 Contact(
534 name: "",
535 address: widget.sendViewModel!.outputs.first.address,
536 type: widget.sendViewModel!.wallet.currency)));
537 }),
538 // lightning has to be hacked in here as it doesn't get added to tx history for a few secs after committing.
539 if (widget.sendViewModel!.transactionInfo != null ||
540 widget.sendViewModel!.currency == CryptoCurrency.btcln)
541 TransactionCommittedScreenActionButton(
542 text: S.of(context).add_a_note,
543 iconPath: "assets/new-ui/add_note.svg",
544 isLoading: _isNoteButtonLoading,
545 onTap: () async {
546 setState(() {
547 _isNoteButtonLoading = true;
548 });
549
550 // for ln, we want to show the button and just have it wait until it appears in tx history
551 // for other currs this is instant
552 await asyncWhen((_) => widget.sendViewModel!.transactionInfo != null);
553
554 setState(() {
555 _isNoteButtonLoading = false;
556 });
557
558 final page = getIt.get<TransactionDetailsModal>(
559 param1: widget.sendViewModel!.transactionInfo!, param2: true);
560 showModalBottomSheet(
561 isScrollControlled: true,
562 context: context,
563 builder: (context) =>
564 FractionallySizedBox(heightFactor: 0.9, child: page));
565 }),
566 ],
567 ),
568 NewPrimaryButton(
569 onPressed: Navigator.of(context).maybePop,
570 text: S.of(context).done,
571 color: Theme.of(context).colorScheme.primary,
572 textColor: Theme.of(context).colorScheme.onPrimary),
573 SizedBox(
574 height: 12,
575 )
576 ],
577 ),
578 ),
579 ],
580 ),
581 );
582 }
583 }
584
585 class TransactionCommittedScreenActionButton extends StatelessWidget {
586 const TransactionCommittedScreenActionButton(
587 {super.key,
588 required this.text,
589 required this.iconPath,
590 required this.onTap,
591 this.isLoading = false});
592
593 final String text;
594 final String iconPath;
595 final VoidCallback onTap;
596 final bool isLoading;
597
598 @override
599 Widget build(BuildContext context) {
600 return Flexible(
601 child: Semantics(
602 button: true,
603 enabled: !isLoading,
604 label: text,
605 value: isLoading ? S.of(context).loading : null,
606 onTap: isLoading ? null : onTap,
607 excludeSemantics: true,
608 child: GestureDetector(
609 onTap: isLoading ? null : onTap,
610 child: Container(
611 decoration: BoxDecoration(
612 borderRadius: BorderRadius.circular(16),
613 color: Theme.of(context).colorScheme.surfaceContainer),
614 child: Padding(
615 padding: EdgeInsets.all(16),
616 child: Row(
617 mainAxisAlignment: MainAxisAlignment.center,
618 spacing: 10,
619 children: [
620 isLoading
621 ? CupertinoActivityIndicator()
622 : CakeImageWidget(
623 imageUrl: iconPath,
624 width: 24,
625 height: 24,
626 colorFilter: ColorFilter.mode(
627 Theme.of(context).colorScheme.primary, BlendMode.srcIn),
628 ),
629 Text(
630 text,
631 style: TextStyle(
632 color: Theme.of(context).colorScheme.primary,
633 fontWeight: FontWeight.w500),
634 )
635 ],
636 ),
637 ),
638 ))));
639 }
640 }
641
642 class MultiSendAddressPreview extends StatefulWidget {
643 const MultiSendAddressPreview(
644 {super.key,
645 required this.index,
646 required this.address,
647 required this.amount,
648 required this.fiatAmount});
649
650 final int index;
651 final String address;
652 final String amount;
653 final String fiatAmount;
654
655 @override
656 State<MultiSendAddressPreview> createState() => _MultiSendAddressPreviewState();
657 }
658
659 class _MultiSendAddressPreviewState extends State<MultiSendAddressPreview> {
660 bool _expanded = false;
661
662 @override
663 Widget build(BuildContext context) {
664 return Padding(
665 padding: const EdgeInsets.all(12.0),
666 child: Row(
667 mainAxisAlignment: MainAxisAlignment.spaceBetween,
668 spacing: 4,
669 children: [
670 Flexible(
671 child: Column(
672 crossAxisAlignment: CrossAxisAlignment.start,
673 children: [
674 Text(
675 "${widget.index}:",
676 style: TextStyle(fontFamily: "IBM Plex Mono"),
677 ),
678 if (!_expanded)
679 Semantics(
680 button: true,
681 // Read the whole address rather than the truncated form.
682 label: widget.address,
683 hint: S.of(context).show_full_address,
684 onTap: () {
685 setState(() {
686 _expanded = true;
687 });
688 },
689 excludeSemantics: true,
690 child: GestureDetector(
691 onTap: () {
692 setState(() {
693 _expanded = true;
694 });
695 },
696 child: Text(middleTruncate(widget.address, 8, 8),
697 style: TextStyle(
698 fontFamily: "IBM Plex Mono",
699 color: Theme.of(context).colorScheme.primary))),
700 )
701 else
702 AddressFormatter.buildSegmentedAddress(
703 address: widget.address,
704 evenTextStyle: TextStyle(
705 fontSize: 12,
706 color: Theme.of(context).colorScheme.onSurface,
707 fontFamily: "IBM Plex Mono")),
708 ],
709 ),
710 ),
711 Column(
712 crossAxisAlignment: CrossAxisAlignment.end,
713 children: [
714 Text(widget.amount),
715 Text(
716 widget.fiatAmount,
717 style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
718 )
719 ],
720 ),
721 ],
722 ),
723 );
724 }
725 }