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