dev
dart 558 lines 21.9 KB
Raw
1 import 'package:cake_wallet/buy/sell_buy_states.dart';
2 import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart';
3 import 'package:cake_wallet/core/address_validator.dart';
4 import 'package:cake_wallet/di.dart';
5 import 'package:cake_wallet/generated/i18n.dart';
6 import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
7 import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart';
8 import 'package:cake_wallet/new-ui/widgets/currency_picker/fiat_currency_picker_sheet.dart';
9 import 'package:cake_wallet/routes.dart';
10 import 'package:cake_wallet/src/screens/base_page.dart';
11 import 'package:cake_wallet/src/screens/exchange/widgets/desktop_exchange_cards_section.dart';
12 import 'package:cake_wallet/src/screens/exchange/widgets/exchange_card.dart';
13 import 'package:cake_wallet/src/screens/exchange/widgets/mobile_exchange_cards_section.dart';
14 import 'package:cake_wallet/src/widgets/keyboard_done_button.dart';
15 import 'package:cake_wallet/src/widgets/primary_button.dart';
16 import 'package:cake_wallet/src/widgets/provider_optoin_tile.dart';
17 import 'package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart';
18 import 'package:cake_wallet/src/widgets/trail_button.dart';
19 import 'package:cake_wallet/utils/responsive_layout_util.dart';
20 import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart';
21 import 'package:cw_core/crypto_currency.dart';
22 import 'package:cw_core/currency.dart';
23 import 'package:cw_core/wallet_type.dart';
24 import 'package:flutter/material.dart';
25 import 'package:flutter_mobx/flutter_mobx.dart';
26 import 'package:keyboard_actions/keyboard_actions.dart';
27 import 'package:mobx/mobx.dart';
28
29 class BuySellPage extends BasePage {
30 BuySellPage(this.buySellViewModel, this._resolver);
31
32 final BuySellViewModel buySellViewModel;
33 final AddressResolverService _resolver;
34 final cryptoCurrencyKey = GlobalKey<ExchangeCardState>();
35 final fiatCurrencyKey = GlobalKey<ExchangeCardState>();
36 final _formKey = GlobalKey<FormState>();
37 final _fiatAmountFocus = FocusNode();
38 final _cryptoAmountFocus = FocusNode();
39 final _cryptoAddressFocus = FocusNode();
40 var _isReactionsSet = false;
41
42 late final String? depositWalletName;
43 late final String? receiveWalletName;
44
45 @override
46 String get title => S.current.buy + '/' + S.current.sell;
47
48 @override
49 bool get gradientBackground => true;
50
51 @override
52 bool get gradientAll => true;
53
54 @override
55 bool get resizeToAvoidBottomInset => false;
56
57 @override
58 bool get extendBodyBehindAppBar => true;
59
60 @override
61 AppBarStyle get appBarStyle => AppBarStyle.completelyTransparent;
62
63 @override
64 Function(BuildContext)? get pushToNextWidget => (context) {
65 FocusScopeNode currentFocus = FocusScope.of(context);
66 if (!currentFocus.hasPrimaryFocus) {
67 currentFocus.focusedChild?.unfocus();
68 }
69 };
70
71 @override
72 Widget trailing(BuildContext context) => TrailButton(
73 caption: S.of(context).clear,
74 onPressed: () {
75 _formKey.currentState?.reset();
76 buySellViewModel.reset();
77 });
78
79 @override
80 Widget? leading(BuildContext context) {
81 final _backButton = Icon(
82 Icons.arrow_back_ios,
83 color: Theme.of(context).colorScheme.primary,
84 size: 16,
85 );
86 final _closeButton = currentTheme.isDark ? closeButtonImageDarkTheme : closeButtonImage;
87
88 bool isMobileView = responsiveLayoutUtil.shouldRenderMobileUI;
89
90 return MergeSemantics(
91 child: SizedBox(
92 height: isMobileView ? 37 : 45,
93 width: isMobileView ? 37 : 45,
94 child: ButtonTheme(
95 minWidth: double.minPositive,
96 child: Semantics(
97 label: !isMobileView ? S.of(context).close : S.of(context).seed_alert_back,
98 child: TextButton(
99 style: ButtonStyle(
100 overlayColor: WidgetStateColor.resolveWith((states) => Colors.transparent),
101 ),
102 onPressed: () => onClose(context),
103 child: !isMobileView ? _closeButton : _backButton,
104 ),
105 ),
106 ),
107 ),
108 );
109 }
110
111 @override
112 Widget body(BuildContext context) {
113 WidgetsBinding.instance.addPostFrameCallback((_) => _setReactions(context, buySellViewModel));
114
115 return KeyboardActions(
116 disableScroll: true,
117 config: KeyboardActionsConfig(
118 keyboardActionsPlatform: KeyboardActionsPlatform.IOS,
119 keyboardBarColor: Theme.of(context).colorScheme.surface,
120 nextFocus: false,
121 actions: [
122 KeyboardActionsItem(
123 focusNode: _fiatAmountFocus, toolbarButtons: [(_) => KeyboardDoneButton()]),
124 KeyboardActionsItem(
125 focusNode: _cryptoAmountFocus, toolbarButtons: [(_) => KeyboardDoneButton()])
126 ]),
127 child: Container(
128 color: Theme.of(context).colorScheme.surface,
129 child: Form(
130 key: _formKey,
131 child: ScrollableWithBottomSection(
132 contentPadding: EdgeInsets.only(bottom: 24),
133 content: Observer(
134 builder: (_) => Column(
135 children: [
136 _exchangeCardsSection(context),
137 Padding(
138 padding: EdgeInsets.symmetric(horizontal: 24),
139 child: Column(
140 children: [
141 SizedBox(height: 12),
142 _buildPaymentMethodTile(context),
143 ],
144 ),
145 ),
146 ],
147 ),
148 ),
149 bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24),
150 bottomSection: Observer(
151 builder: (_) => Column(
152 children: [
153 if (buySellViewModel.isBuySellQuoteFailed)
154 Padding(
155 padding: EdgeInsets.only(bottom: 15),
156 child: Row(
157 children: [
158 Expanded(
159 child: Container(
160 alignment: Alignment.centerRight,
161 child: Icon(
162 Icons.warning_amber_rounded,
163 color: Theme.of(context).colorScheme.error,
164 size: 26,
165 ),
166 ),
167 ),
168 Expanded(
169 flex: 8,
170 child: Text(
171 buySellViewModel.buySellQuoteFailedError ??
172 S.of(context).buy_sell_pair_is_not_supported_warning,
173 textAlign: TextAlign.center,
174 softWrap: true,
175 overflow: TextOverflow.ellipsis,
176 maxLines: 3,
177 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
178 color: Theme.of(context).colorScheme.error,
179 fontWeight: FontWeight.w500,
180 ),
181 ),
182 ),
183 ],
184 ),
185 ),
186 LoadingPrimaryButton(
187 text: S.current.choose_a_provider,
188 onPressed: () async {
189 if (!_formKey.currentState!.validate()) return;
190 buySellViewModel.onTapChoseProvider(context);
191 },
192 color: Theme.of(context).colorScheme.primary,
193 textColor: Theme.of(context).colorScheme.onPrimary,
194 isDisabled: buySellViewModel.isBuySellQuoteFailed,
195 isLoading:
196 !buySellViewModel.isReadyToTrade && !buySellViewModel.isBuySellQuoteFailed,
197 ),
198 ],
199 ),
200 ),
201 ),
202 ),
203 ),
204 );
205 }
206
207 Widget _buildPaymentMethodTile(BuildContext context) {
208 if (buySellViewModel.paymentMethodState is PaymentMethodLoading ||
209 buySellViewModel.paymentMethodState is InitialPaymentMethod) {
210 return OptionTilePlaceholder(
211 withBadge: false,
212 withSubtitle: false,
213 borderRadius: 30,
214 padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
215 leadingIcon: Icons.arrow_forward_ios,
216 isDarkTheme: currentTheme.isDark);
217 }
218 if (buySellViewModel.paymentMethodState is PaymentMethodFailed) {
219 return OptionTilePlaceholder(errorText: 'No payment methods available', borderRadius: 30);
220 }
221 if (buySellViewModel.paymentMethodState is PaymentMethodLoaded &&
222 buySellViewModel.selectedPaymentMethod != null) {
223 final selectedPaymentMethod = buySellViewModel.selectedPaymentMethod!;
224 return ProviderOptionTile(
225 lightImagePath: selectedPaymentMethod.lightIconPath,
226 darkImagePath: selectedPaymentMethod.darkIconPath,
227 title: selectedPaymentMethod.title,
228 onPressed: () => _pickPaymentMethod(context),
229 leadingIcon: Icons.arrow_forward_ios,
230 isLightMode: !currentTheme.isDark,
231 borderRadius: 30,
232 padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
233 titleTextStyle: Theme.of(context).textTheme.titleMedium?.copyWith(
234 color: Theme.of(context).colorScheme.onSurface,
235 fontWeight: FontWeight.w600,
236 ),
237 );
238 }
239 return OptionTilePlaceholder(errorText: 'No payment methods available', borderRadius: 30);
240 }
241
242 void _pickPaymentMethod(BuildContext context) async {
243 final currentOption = buySellViewModel.selectedPaymentMethod;
244 await Navigator.of(context).pushNamed(
245 Routes.paymentMethodOptionsPage,
246 arguments: [
247 buySellViewModel.paymentMethods,
248 buySellViewModel.changeOption,
249 ],
250 );
251
252 buySellViewModel.selectedPaymentMethod;
253 if (currentOption != null &&
254 currentOption.paymentMethodType !=
255 buySellViewModel.selectedPaymentMethod?.paymentMethodType) {
256 await buySellViewModel.calculateBestRate();
257 }
258 }
259
260 void _setReactions(BuildContext context, BuySellViewModel buySellViewModel) {
261 if (_isReactionsSet) return;
262
263 final fiatAmountController = fiatCurrencyKey.currentState!.amountController;
264 final cryptoAmountController = cryptoCurrencyKey.currentState!.amountController;
265 final cryptoAddressController = cryptoCurrencyKey.currentState!.addressController;
266
267 cryptoAddressController.addListener(() {
268 buySellViewModel.changeCryptoCurrencyAddress(cryptoAddressController.text);
269 });
270
271 _onCurrencyChange(buySellViewModel.cryptoCurrency, buySellViewModel, cryptoCurrencyKey);
272 _onCurrencyChange(buySellViewModel.fiatCurrency, buySellViewModel, fiatCurrencyKey);
273
274 reaction(
275 (_) => buySellViewModel.wallet.name,
276 (_) => _onWalletNameChange(
277 buySellViewModel, buySellViewModel.cryptoCurrency, cryptoCurrencyKey));
278
279 reaction((_) => buySellViewModel.cryptoCurrency,
280 (currency) => _onCurrencyChange(currency, buySellViewModel, cryptoCurrencyKey));
281
282 reaction((_) => buySellViewModel.fiatCurrency,
283 (currency) => _onCurrencyChange(currency, buySellViewModel, fiatCurrencyKey));
284
285 reaction((_) => buySellViewModel.fiatAmount, (amount) {
286 if (fiatCurrencyKey.currentState!.amountController.text != amount) {
287 fiatCurrencyKey.currentState!.amountController.text = amount;
288 }
289 });
290
291 reaction((_) => buySellViewModel.isCryptoCurrencyAddressEnabled, (isEnabled) {
292 cryptoCurrencyKey.currentState!.isAddressEditable(isEditable: isEnabled);
293 });
294
295 reaction((_) => buySellViewModel.cryptoAmount, (amount) {
296 if (cryptoCurrencyKey.currentState!.amountController.text != amount) {
297 cryptoCurrencyKey.currentState!.amountController.text = amount;
298 }
299 });
300
301 reaction((_) => buySellViewModel.cryptoCurrencyAddress, (String address) {
302 if (cryptoAddressController.text != address) {
303 cryptoCurrencyKey.currentState!.addressController.text = address;
304 }
305 });
306
307 fiatAmountController.addListener(() {
308 if (fiatAmountController.text != buySellViewModel.fiatAmount) {
309 buySellViewModel.changeFiatAmount(amount: fiatAmountController.text);
310 }
311 });
312
313 cryptoAmountController.addListener(() {
314 if (cryptoAmountController.text != buySellViewModel.cryptoAmount) {
315 buySellViewModel.changeCryptoAmount(amount: cryptoAmountController.text);
316 }
317 });
318
319 _cryptoAddressFocus.addListener(() async {
320 if (!_cryptoAddressFocus.hasFocus && cryptoAddressController.text.isNotEmpty) {
321 final domain = cryptoAddressController.text;
322 final parsed = await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency);
323 if (parsed.isNotEmpty) {
324 buySellViewModel.cryptoCurrencyAddress = parsed;
325 }
326 }
327 });
328
329 reaction((_) => buySellViewModel.wallet.walletAddresses.addressForBuy, (String address) {
330 if (buySellViewModel.cryptoCurrency == CryptoCurrency.xmr) {
331 cryptoCurrencyKey.currentState!.changeAddress(address: address);
332 }
333 });
334
335 reaction((_) => buySellViewModel.isReadyToTrade, (bool isReady) {
336 if (isReady) {
337 if (buySellViewModel.skipIsReadyToTradeReaction) {
338 buySellViewModel.skipIsReadyToTradeReaction = false;
339 return;
340 }
341 if (cryptoAmountController.text.isNotEmpty &&
342 cryptoAmountController.text != S.current.fetching) {
343 buySellViewModel.changeCryptoAmount(amount: cryptoAmountController.text);
344 } else if (fiatAmountController.text.isNotEmpty &&
345 fiatAmountController.text != S.current.fetching) {
346 buySellViewModel.changeFiatAmount(amount: fiatAmountController.text);
347 }
348 }
349 });
350
351 _isReactionsSet = true;
352 }
353
354 void _onCurrencyChange(
355 Currency currency, BuySellViewModel buySellViewModel, GlobalKey<ExchangeCardState> key) {
356 final isCurrentTypeWallet = currency == buySellViewModel.wallet.currency;
357
358 key.currentState!.changeSelectedCurrency(currency);
359 key.currentState!.changeWalletName(isCurrentTypeWallet ? buySellViewModel.wallet.name : '');
360
361 key.currentState!.changeAddress(
362 address: isCurrentTypeWallet ? buySellViewModel.wallet.walletAddresses.addressForBuy : '');
363
364 key.currentState!.changeAmount(amount: '');
365 }
366
367 void _onWalletNameChange(BuySellViewModel buySellViewModel, CryptoCurrency currency,
368 GlobalKey<ExchangeCardState> key) {
369 final isCurrentTypeWallet = currency == buySellViewModel.wallet.currency;
370
371 if (isCurrentTypeWallet) {
372 key.currentState!.changeWalletName(buySellViewModel.wallet.name);
373 key.currentState!.addressController.text =
374 buySellViewModel.wallet.walletAddresses.addressForBuy;
375 } else if (key.currentState!.addressController.text ==
376 buySellViewModel.wallet.walletAddresses.addressForBuy) {
377 key.currentState!.changeWalletName('');
378 key.currentState!.addressController.text = '';
379 }
380 }
381
382 void disposeBestRateSync() => {};
383
384 Widget _exchangeCardsSection(BuildContext context) {
385 final fiatExchangeCard = Observer(
386 builder: (_) => ExchangeCard(
387 cardInstanceName: 'fiat_currency_trade_card',
388 onDispose: disposeBestRateSync,
389 amountFocusNode: _fiatAmountFocus,
390 key: fiatCurrencyKey,
391 title: 'FIAT ${S.of(context).amount}',
392 initialCurrency: buySellViewModel.fiatCurrency,
393 initialWalletName: '',
394 initialAddress: '',
395 initialIsAmountEditable: true,
396 isAmountEstimated: false,
397 currencyRowPadding: EdgeInsets.zero,
398 addressRowPadding: EdgeInsets.zero,
399 isMoneroWallet: buySellViewModel.wallet == WalletType.monero,
400 showAddressField: false,
401 showLimitsField: false,
402 currencies: buySellViewModel.fiatCurrencies,
403 onCurrencySelected: (currency) => buySellViewModel.changeFiatCurrency(currency: currency),
404 onTapCurrencyPicker: _presentFiatPicker,
405 imageArrow: Image.asset(
406 'assets/images/arrow_bottom_purple_icon.png',
407 color: Theme.of(context).colorScheme.primary,
408 height: 8,
409 ),
410 currencyButtonColor: Colors.transparent,
411 addressButtonsColor: Theme.of(context).colorScheme.surfaceContainerHighest,
412 borderColor: Theme.of(context).colorScheme.outlineVariant,
413 onPushPasteButton: (context) async {
414 final domain = cryptoCurrencyKey.currentState!.addressController.text;
415 final parsed = await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency);
416 if (parsed.isNotEmpty) {
417 buySellViewModel.cryptoCurrencyAddress = parsed;
418 }
419 },
420 onPushAddressBookButton: (context) async {
421 final domain = cryptoCurrencyKey.currentState!.addressController.text;
422 final parsed = await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency);
423 if (parsed.isNotEmpty) {
424 buySellViewModel.cryptoCurrencyAddress = parsed;
425 }
426 },
427 fillColor: buySellViewModel.isBuyAction
428 ? Theme.of(context).colorScheme.surfaceContainer
429 : Theme.of(context).colorScheme.surfaceContainerLow,
430 ),
431 );
432
433 final cryptoExchangeCard = Observer(
434 builder: (_) => ExchangeCard(
435 cardInstanceName: 'crypto_currency_trade_card',
436 onDispose: disposeBestRateSync,
437 amountFocusNode: _cryptoAmountFocus,
438 addressFocusNode: _cryptoAddressFocus,
439 key: cryptoCurrencyKey,
440 title: 'Crypto ${S.of(context).amount}',
441 initialCurrency: buySellViewModel.cryptoCurrency,
442 initialWalletName: '',
443 initialAddress: buySellViewModel.cryptoCurrency == buySellViewModel.wallet.currency
444 ? buySellViewModel.wallet.walletAddresses.addressForBuy
445 : buySellViewModel.cryptoCurrencyAddress,
446 initialIsAmountEditable: true,
447 isAmountEstimated: true,
448 showLimitsField: false,
449 currencyRowPadding: EdgeInsets.zero,
450 addressRowPadding: EdgeInsets.zero,
451 isMoneroWallet: buySellViewModel.wallet == WalletType.monero,
452 currencies: buySellViewModel.cryptoCurrencies,
453 onCurrencySelected: (currency) => buySellViewModel.changeCryptoCurrency(currency: currency),
454 onTapCurrencyPicker: _presentCryptoPicker,
455 imageArrow: Image.asset(
456 'assets/images/arrow_bottom_cake_green.png',
457 color: Theme.of(context).colorScheme.primary,
458 height: 8,
459 ),
460 currencyButtonColor: Colors.transparent,
461 addressButtonsColor: Theme.of(context).colorScheme.surfaceContainerHighest,
462 borderColor: Theme.of(context).colorScheme.outlineVariant,
463 addressTextFieldValidator: AddressValidator(type: buySellViewModel.cryptoCurrency),
464 onPushPasteButton: (context) async {},
465 onPushAddressBookButton: (context) async {},
466 fillColor: buySellViewModel.isBuyAction
467 ? Theme.of(context).colorScheme.surfaceContainerLow
468 : Theme.of(context).colorScheme.surfaceContainer,
469 useSatoshis: buySellViewModel.useSatoshi,
470 ),
471 );
472
473 if (responsiveLayoutUtil.shouldRenderMobileUI) {
474 return Observer(
475 builder: (_) {
476 if (buySellViewModel.isBuyAction) {
477 return MobileExchangeCardsSection(
478 firstExchangeCard: fiatExchangeCard,
479 secondExchangeCard: cryptoExchangeCard,
480 onBuyTap: () => null,
481 onSellTap: () =>
482 buySellViewModel.isBuyAction ? buySellViewModel.changeBuySellAction() : null,
483 isBuySellOption: true,
484 );
485 } else {
486 return MobileExchangeCardsSection(
487 firstExchangeCard: cryptoExchangeCard,
488 secondExchangeCard: fiatExchangeCard,
489 onBuyTap: () =>
490 !buySellViewModel.isBuyAction ? buySellViewModel.changeBuySellAction() : null,
491 onSellTap: () => null,
492 isBuySellOption: true,
493 );
494 }
495 },
496 );
497 }
498
499 return Observer(
500 builder: (_) {
501 if (buySellViewModel.isBuyAction) {
502 return DesktopExchangeCardsSection(
503 firstExchangeCard: fiatExchangeCard,
504 secondExchangeCard: cryptoExchangeCard,
505 onBuyTap: () => null,
506 onSellTap: () =>
507 buySellViewModel.isBuyAction ? buySellViewModel.changeBuySellAction() : null,
508 isBuySellOption: true,
509 );
510 } else {
511 return DesktopExchangeCardsSection(
512 firstExchangeCard: cryptoExchangeCard,
513 secondExchangeCard: fiatExchangeCard,
514 onBuyTap: () =>
515 !buySellViewModel.isBuyAction ? buySellViewModel.changeBuySellAction() : null,
516 onSellTap: () => null,
517 isBuySellOption: true,
518 );
519 }
520 },
521 );
522 }
523
524 void _presentFiatPicker(BuildContext context) {
525 FiatCurrencyPickerSheet.show(
526 context: context,
527 selected: buySellViewModel.fiatCurrency,
528 onSelected: (currency) => buySellViewModel.changeFiatCurrency(currency: currency),
529 );
530 }
531
532 void _presentCryptoPicker(BuildContext context) {
533 final items = [...buySellViewModel.cryptoCurrencies];
534 appendEvmDefaultTokens(items);
535 CurrencyPickerSheet.show(
536 context: context,
537 args: CurrencyPickerArgs(
538 items: items,
539 selected: buySellViewModel.cryptoCurrency,
540 recentsSource: RecentsSource.orders,
541 onSelected: (currency) => buySellViewModel.changeCryptoCurrency(currency: currency),
542 symbolResolver: buySellViewModel.amountParsingProxy.getCryptoSymbol,
543 ),
544 );
545 }
546
547 Future<String> fetchParsedAddress(
548 BuildContext context,
549 String domain,
550 CryptoCurrency currency,
551 ) async {
552 final parsedAddresses =
553 await _resolver.resolve(query: domain, wallet: buySellViewModel.wallet, currency: currency);
554 return parsedAddresses.isNotEmpty
555 ? parsedAddresses.first.parsedAddressByCurrencyMap[currency] ?? ''
556 : '';
557 }
558 }