dev
dart 604 lines 22.2 KB
Raw
1 import 'package:cake_wallet/core/amount_validator.dart';
2 import 'package:cake_wallet/core/auth_service.dart';
3 import 'package:cake_wallet/di.dart';
4 import 'package:cake_wallet/exchange/limits_state.dart';
5 import 'package:cake_wallet/generated/i18n.dart';
6 import 'package:cake_wallet/reactions/wallet_connect.dart';
7 import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker.dart';
8 import 'package:cake_wallet/src/widgets/base_text_form_field.dart';
9 import 'package:cake_wallet/src/widgets/bottom_sheet/swap_details_bottom_sheet.dart';
10 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
11 import 'package:cake_wallet/utils/address_formatter.dart';
12 import 'package:cake_wallet/utils/debounce.dart';
13 import 'package:cw_core/amount/amount_sanitizer.dart';
14 import 'package:cw_core/currency_for_wallet_type.dart';
15 import 'package:cw_core/crypto_amount_format.dart';
16
17 import 'package:flutter/material.dart';
18 import 'package:flutter/services.dart';
19 import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
20 import 'package:cake_wallet/src/widgets/primary_button.dart';
21 import 'package:cw_core/wallet_type.dart';
22 import 'package:cake_wallet/view_model/payment/payment_view_model.dart';
23 import 'package:cake_wallet/view_model/exchange/exchange_view_model.dart';
24 import 'package:cake_wallet/exchange/exchange_trade_state.dart';
25 import 'package:flutter_mobx/flutter_mobx.dart';
26 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
27 import 'package:cake_wallet/utils/show_pop_up.dart';
28 import 'package:mobx/mobx.dart';
29
30 class SwapConfirmationBottomSheet extends BaseBottomSheet {
31 SwapConfirmationBottomSheet({
32 Key? key,
33 required this.paymentFlowResult,
34 required this.exchangeViewModel,
35 required this.authService,
36 this.sessionId,
37 }) : super(
38 titleText: S.current.swap,
39 footerType: FooterType.none,
40 maxHeight: 900,
41 );
42
43 final PaymentFlowResult paymentFlowResult;
44 final ExchangeViewModel exchangeViewModel;
45 final AuthService authService;
46 final String? sessionId;
47 @override
48 Widget contentWidget(BuildContext context) {
49 return SingleChildScrollView(
50 padding: EdgeInsets.only(
51 bottom: MediaQuery.of(context).viewInsets.bottom,
52 ),
53 child: SwapConfirmationContent(
54 paymentFlowResult: paymentFlowResult,
55 exchangeViewModel: exchangeViewModel,
56 authService: authService,
57 ),
58 );
59 }
60 }
61
62 class SwapConfirmationContent extends StatefulWidget {
63 const SwapConfirmationContent({
64 Key? key,
65 required this.paymentFlowResult,
66 required this.exchangeViewModel,
67 required this.authService,
68 }) : super(key: key);
69
70 final PaymentFlowResult paymentFlowResult;
71 final ExchangeViewModel exchangeViewModel;
72 final AuthService authService;
73
74 @override
75 SwapConfirmationContentState createState() => SwapConfirmationContentState();
76 }
77
78 class SwapConfirmationContentState extends State<SwapConfirmationContent> {
79 late TextEditingController _amountController;
80 late TextEditingController _amountFiatController;
81 late TextEditingController _addressController;
82
83 final _receiveAmountDebounce = Debounce(Duration(milliseconds: 500));
84 final _receiveAmountFiatDebounce = Debounce(Duration(milliseconds: 500));
85 final FocusNode _amountFocus = FocusNode();
86 final FocusNode _amountFiatFocus = FocusNode();
87 final FocusNode _addressFocus = FocusNode();
88 final _formKey = GlobalKey<FormState>();
89
90 ReactionDisposer? _receiveAmountReaction;
91 ReactionDisposer? _receiveAddressReaction;
92 ReactionDisposer? _tradeStateReaction;
93 ReactionDisposer? _bestRateReaction;
94 ReactionDisposer? _receiveAmountFiatReaction;
95
96 bool _showingFailureDialog = false;
97 bool _showingSwapDetailsDialog = false;
98 bool _isUserTypingFiat = false;
99
100 @override
101 void initState() {
102 super.initState();
103 _addressController =
104 TextEditingController(text: widget.paymentFlowResult.addressDetectionResult?.address ?? '');
105 _amountController = TextEditingController(
106 text: widget.paymentFlowResult.addressDetectionResult?.amount?.isNotEmpty ?? false
107 ? widget.paymentFlowResult.addressDetectionResult?.amount
108 : '0.00');
109 _amountFiatController =
110 TextEditingController(text: widget.exchangeViewModel.receiveAmountFiatFormatted);
111
112 WidgetsBinding.instance.addPostFrameCallback(
113 (_) => _setUpReactions(
114 context,
115 widget.exchangeViewModel,
116 widget.paymentFlowResult,
117 ),
118 );
119 }
120
121 @override
122 void dispose() {
123 _amountController.dispose();
124 _amountFiatController.dispose();
125 _addressController.dispose();
126 _amountFocus.dispose();
127 _amountFiatFocus.dispose();
128 _addressFocus.dispose();
129 _receiveAmountReaction?.call();
130 _receiveAddressReaction?.call();
131 _tradeStateReaction?.call();
132 _bestRateReaction?.call();
133 _receiveAmountFiatReaction?.call();
134 _showingFailureDialog = false;
135 _showingSwapDetailsDialog = false;
136 widget.exchangeViewModel.bestRateSync.cancel();
137 super.dispose();
138 }
139
140 @override
141 Widget build(BuildContext context) {
142 final detectedCurrency = widget.paymentFlowResult.detectedCurrency!;
143
144 return Form(
145 key: _formKey,
146 child: Padding(
147 padding: const EdgeInsets.symmetric(horizontal: 16),
148 child: Column(
149 mainAxisSize: MainAxisSize.min,
150 crossAxisAlignment: CrossAxisAlignment.start,
151 children: [
152 Row(
153 mainAxisAlignment: MainAxisAlignment.center,
154 children: [
155 CakeImageWidget(
156 imageUrl: widget.exchangeViewModel.depositCurrency.iconPath!,
157 width: 32,
158 height: 32,
159 ),
160 const SizedBox(width: 12),
161 Icon(Icons.arrow_forward, size: 24),
162 const SizedBox(width: 12),
163 Stack(
164 clipBehavior: Clip.none,
165 children: [
166 CakeImageWidget(
167 imageUrl: detectedCurrency.iconPath ?? '',
168 width: 32,
169 height: 32,
170 ),
171 if (isEVMCompatibleChain(widget.paymentFlowResult.walletType!)) ...[
172 Positioned(
173 bottom: -4,
174 right: -4,
175 child: CakeImageWidget(
176 imageUrl: getCryptoCurrencyIconForWalletListItem(
177 widget.paymentFlowResult.walletType!,
178 chainId: widget.paymentFlowResult.chainId,
179 ),
180 width: 16,
181 height: 16,
182 ),
183 ),
184 ],
185 ],
186 ),
187 ],
188 ),
189 const SizedBox(height: 16),
190 SwapConfirmationTextfield(
191 key: ValueKey('swap_confirmation_bottomsheet_amount_textfield_key'),
192 hintText: 'Amount (${detectedCurrency})',
193 focusNode: _amountFocus,
194 controller: _amountController,
195 keyboardType: TextInputType.numberWithOptions(decimal: true, signed: false),
196 inputFormatters: [
197 FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')),
198 ],
199 onChanged: (value) {
200 final sanitized = value
201 .sanitized()
202 .withMaxDecimals(widget.exchangeViewModel.receiveCurrency.decimals);
203 if (sanitized != _amountController.text) {
204 // Update text while preserving a sane cursor position to avoid auto-selection
205 _amountController.value = _amountController.value.copyWith(
206 text: sanitized,
207 selection: TextSelection.collapsed(offset: sanitized.length),
208 composing: TextRange.empty,
209 );
210 }
211 },
212 validator: (value) {
213 return AmountValidator(
214 isAutovalidate: true,
215 currency: widget.exchangeViewModel.receiveCurrency,
216 amountParsingProxy: widget.exchangeViewModel.amountParsingProxy,
217 minValue: widget.exchangeViewModel.limits.min.toString(),
218 maxValue: widget.exchangeViewModel.limits.max.toString(),
219 ).call(value);
220 },
221 ),
222 Observer(
223 builder: (_) {
224 String? min = '0.0';
225 String? max = '0.0';
226
227 final limitsState = widget.exchangeViewModel.limitsState;
228 if (limitsState is LimitsLoadedSuccessfully) {
229 min = limitsState.limits.min?.toString();
230 max = limitsState.limits.max?.toString();
231 }
232
233 if (limitsState is LimitsLoadedFailure) {
234 min = '0.0';
235 max = '0.0';
236 }
237
238 if (limitsState is LimitsIsLoading) {
239 min = '...';
240 max = '...';
241 }
242 if (min != null || max != null) {
243 return Container(
244 height: 15,
245 child: Row(
246 mainAxisAlignment: MainAxisAlignment.start,
247 children: <Widget>[
248 min != null
249 ? Text(
250 key: ValueKey('min_limit_text_key'),
251 S.of(context).min_value(min, detectedCurrency.toString()),
252 style: Theme.of(context).textTheme.bodySmall!.copyWith(
253 fontSize: 10,
254 height: 1.2,
255 color: Theme.of(context).colorScheme.onSurfaceVariant,
256 ),
257 )
258 : Offstage(),
259 min != null ? SizedBox(width: 10) : Offstage(),
260 max != null
261 ? Text(
262 key: ValueKey('max_limit_text_key'),
263 S.of(context).max_value(max, detectedCurrency.toString()),
264 style: Theme.of(context).textTheme.bodySmall!.copyWith(
265 fontSize: 10,
266 height: 1.2,
267 color: Theme.of(context).colorScheme.onSurfaceVariant,
268 ),
269 )
270 : Offstage(),
271 ],
272 ),
273 );
274 }
275
276 return SizedBox.shrink();
277 },
278 ),
279 const SizedBox(height: 8),
280 SwapConfirmationTextfield(
281 key: ValueKey('swap_confirmation_bottomsheet_amount_fiat_textfield_key'),
282 hintText: 'Amount (${widget.exchangeViewModel.fiat.title})',
283 focusNode: _amountFiatFocus,
284 controller: _amountFiatController,
285 keyboardType: TextInputType.numberWithOptions(decimal: true),
286 ),
287 const SizedBox(height: 8),
288 SwapConfirmationTextfield(
289 key: ValueKey('swap_confirmation_bottomsheet_address_textfield_key'),
290 isAddress: true,
291 walletType:
292 cryptoCurrencyOrTokenToWalletType(widget.exchangeViewModel.receiveCurrency),
293 hintText: 'Destination Address',
294 focusNode: _addressFocus,
295 controller: _addressController,
296 ),
297 SizedBox(height: 8),
298 Center(
299 child: Text(
300 'Tap field to edit values',
301 style: Theme.of(context).textTheme.bodySmall!.copyWith(
302 fontSize: 10,
303 color: Theme.of(context).colorScheme.onSurfaceVariant,
304 ),
305 ),
306 ),
307 SizedBox(height: 32),
308 SwapConfirmationFooter(
309 exchangeViewModel: widget.exchangeViewModel,
310 formKey: _formKey,
311 authService: widget.authService,
312 ),
313 ],
314 ),
315 ),
316 );
317 }
318
319 void _setUpReactions(
320 BuildContext context,
321 ExchangeViewModel exchangeViewModel,
322 PaymentFlowResult paymentFlowResult,
323 ) async {
324 _receiveAmountReaction = reaction((_) => exchangeViewModel.receiveAmount, (String amount) {
325 if (_amountController.text != amount) {
326 _amountController.text = amount;
327 }
328 });
329
330 _receiveAmountFiatReaction =
331 reaction((_) => exchangeViewModel.receiveAmountFiatFormatted, (String amount) {
332 if (!_isUserTypingFiat && _amountFiatController.text != amount) {
333 _amountFiatController.text = amount;
334 }
335 });
336
337 _receiveAddressReaction = reaction((_) => exchangeViewModel.receiveAddress, (String address) {
338 if (_addressController.text != address) {
339 _addressController.text = address;
340 }
341 });
342
343 _tradeStateReaction = reaction((_) => exchangeViewModel.tradeState, (ExchangeTradeState state) {
344 if (state is TradeIsCreatedFailure && !_showingFailureDialog) {
345 _showingFailureDialog = true;
346 WidgetsBinding.instance.addPostFrameCallback((_) {
347 if (context.mounted) {
348 showPopUp<void>(
349 context: context,
350 builder: (BuildContext context) {
351 return AlertWithOneAction(
352 key: const ValueKey('swap_confirmation_trade_creation_failure_dialog_key'),
353 buttonKey:
354 const ValueKey('swap_confirmation_trade_creation_failure_dialog_button_key'),
355 alertTitle: S.of(context).provider_error(state.title),
356 alertContent: state.error,
357 buttonText: S.of(context).ok,
358 buttonAction: () {
359 _showingFailureDialog = false;
360 if (Navigator.of(context).canPop()) {
361 Navigator.of(context).pop();
362 }
363 },
364 );
365 },
366 );
367 }
368 });
369 }
370
371 if (state is TradeIsCreatedSuccessfully) {
372 exchangeViewModel.reset();
373 if (Navigator.of(context).canPop() && !_showingSwapDetailsDialog) {
374 _showingSwapDetailsDialog = true;
375 Navigator.of(context).pop();
376 showModalBottomSheet<void>(
377 context: context,
378 isDismissible: true,
379 isScrollControlled: true,
380 builder: (BuildContext context) {
381 _showingSwapDetailsDialog = false;
382 return getIt.get<SwapDetailsBottomSheet>();
383 },
384 );
385 }
386 }
387 });
388
389 _bestRateReaction = reaction((_) => exchangeViewModel.bestRate, (double rate) {
390 if (exchangeViewModel.isFixedRateMode) {
391 exchangeViewModel.changeReceiveAmount(amount: _amountController.text);
392 }
393 });
394
395 _addressController
396 .addListener(() => exchangeViewModel.receiveAddress = _addressController.text);
397
398 _amountController.addListener(() {
399 if (_amountController.text != exchangeViewModel.receiveAmount) {
400 _receiveAmountDebounce.run(() {
401 exchangeViewModel.loadLimits();
402 exchangeViewModel.changeReceiveAmount(amount: _amountController.text);
403 exchangeViewModel.isReceiveAmountEntered = true;
404 });
405 }
406 });
407
408 _amountFiatController.addListener(() {
409 if (_amountFiatController.text != exchangeViewModel.receiveAmountFiatFormatted) {
410 _isUserTypingFiat = true;
411 _receiveAmountFiatDebounce.run(() {
412 exchangeViewModel.loadLimits();
413 exchangeViewModel.setReceiveAmountFromFiat(fiatAmount: _amountFiatController.text);
414 // Reset the flag after the debounced operation completes
415 Future.delayed(Duration(milliseconds: 100), () {
416 _isUserTypingFiat = false;
417 });
418 });
419 }
420 });
421
422 _amountFocus.addListener(() {
423 if (_amountFocus.hasFocus) {
424 exchangeViewModel.enableFixedRateMode();
425 }
426 });
427
428 _amountFiatFocus.addListener(() {
429 if (_amountFiatFocus.hasFocus) {
430 _isUserTypingFiat = true;
431 } else {
432 // Reset the flag when user stops focusing on the field
433 Future.delayed(Duration(milliseconds: 200), () {
434 _isUserTypingFiat = false;
435 });
436 }
437 });
438
439 exchangeViewModel.receiveCurrency = paymentFlowResult.detectedCurrency!;
440
441 await exchangeViewModel.fetchFiatPrice(exchangeViewModel.receiveCurrency);
442
443 exchangeViewModel.receiveAddress = _addressController.text;
444 exchangeViewModel.depositAddress = exchangeViewModel.wallet.walletAddresses.addressForExchange;
445 exchangeViewModel.setCanonicalReceiveAmount(_amountController.text);
446 _amountFiatController.text = exchangeViewModel.receiveAmountFiatFormatted;
447 exchangeViewModel.isReceiveAmountEntered = true;
448 exchangeViewModel.isFixedRateMode = true;
449 }
450 }
451
452 class SwapConfirmationTextfield extends StatelessWidget {
453 const SwapConfirmationTextfield({
454 super.key,
455 required this.focusNode,
456 required this.controller,
457 required this.hintText,
458 this.walletType,
459 this.isAddress = false,
460 this.maxLines = 1,
461 this.validator,
462 this.keyboardType,
463 this.onChanged,
464 this.inputFormatters,
465 });
466
467 final FocusNode focusNode;
468 final TextEditingController controller;
469 final String hintText;
470 final WalletType? walletType;
471 final bool isAddress;
472 final int maxLines;
473 final String? Function(String?)? validator;
474 final TextInputType? keyboardType;
475 final void Function(String)? onChanged;
476 final List<TextInputFormatter>? inputFormatters;
477 @override
478 Widget build(BuildContext context) {
479 return Container(
480 width: double.infinity,
481 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
482 decoration: ShapeDecoration(
483 color: Theme.of(context).colorScheme.surfaceContainer,
484 shape: RoundedRectangleBorder(
485 borderRadius: BorderRadius.circular(10),
486 side: focusNode.hasFocus
487 ? BorderSide(color: Theme.of(context).colorScheme.primary)
488 : BorderSide.none,
489 ),
490 ),
491 child: Column(
492 crossAxisAlignment: CrossAxisAlignment.start,
493 children: [
494 Text(
495 hintText,
496 style: Theme.of(context).textTheme.bodySmall!.copyWith(
497 fontSize: 10,
498 color: Theme.of(context).colorScheme.onSurfaceVariant,
499 ),
500 ),
501 if (isAddress) SizedBox(height: 8),
502 isAddress
503 ? AddressFormatter.buildSegmentedAddress(
504 address: controller.text,
505 walletType: walletType,
506 evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
507 fontWeight: FontWeight.w400,
508 fontSize: 12,
509 ),
510 )
511 : BaseTextFormField(
512 isDense: true,
513 hintText: hintText,
514 focusNode: focusNode,
515 hasUnderlineBorder: true,
516 borderWidth: 0.0,
517 controller: controller,
518 maxLines: maxLines,
519 validator: validator,
520 keyboardType: keyboardType,
521 onChanged: onChanged,
522 inputFormatters: inputFormatters,
523 ),
524 ],
525 ),
526 );
527 }
528 }
529
530 class SwapConfirmationFooter extends StatelessWidget {
531 const SwapConfirmationFooter({
532 super.key,
533 required this.exchangeViewModel,
534 required this.formKey,
535 required this.authService,
536 });
537
538 final ExchangeViewModel exchangeViewModel;
539 final AuthService authService;
540 final GlobalKey<FormState> formKey;
541
542 @override
543 Widget build(BuildContext context) {
544 return Container(
545 height: 150,
546 width: double.infinity,
547 padding: const EdgeInsets.symmetric(horizontal: 8),
548 child: Observer(
549 builder: (_) {
550 final isLoading = exchangeViewModel.tradeState is TradeIsCreating ||
551 exchangeViewModel.limitsState is LimitsIsLoading;
552 final isDisabled = exchangeViewModel.selectedProviders.isEmpty ||
553 exchangeViewModel.receiveAmount.isEmpty ||
554 exchangeViewModel.receiveAddress.isEmpty;
555
556 return Column(
557 mainAxisSize: MainAxisSize.min,
558 children: [
559 PrimaryButton(
560 text: S.current.cancel,
561 onPressed: isLoading
562 ? null
563 : () {
564 if (Navigator.of(context).canPop()) {
565 Navigator.of(context).pop(null);
566 }
567 },
568 color: Theme.of(context).colorScheme.surfaceContainer,
569 textColor: Theme.of(context).colorScheme.onSecondaryContainer,
570 ),
571 const SizedBox(height: 12),
572 LoadingPrimaryButton(
573 text: S.current.continue_text,
574 onPressed: exchangeViewModel.isAvailableInSelected
575 ? () {
576 FocusScope.of(context).unfocus();
577
578 if (formKey.currentState != null && formKey.currentState!.validate()) {
579 final check = exchangeViewModel.shouldDisplayTOTP();
580 authService.authenticateAction(
581 context,
582 conditionToDetermineIfToUse2FA: check,
583 onAuthSuccess: (value) {
584 if (value) {
585 exchangeViewModel.createTrade();
586 }
587 },
588 );
589 }
590 }
591 : () => PresentProviderPicker(exchangeViewModel: exchangeViewModel)
592 .presentProviderPicker(context),
593 color: Theme.of(context).colorScheme.primary,
594 textColor: Theme.of(context).colorScheme.onPrimary,
595 isDisabled: isDisabled,
596 isLoading: isLoading,
597 ),
598 ],
599 );
600 },
601 ),
602 );
603 }
604 }