diff --git a/lib/buy/onramper/onramper_buy_provider.dart b/lib/buy/onramper/onramper_buy_provider.dart index b1a855f734..691d4ed5c8 100644 --- a/lib/buy/onramper/onramper_buy_provider.dart +++ b/lib/buy/onramper/onramper_buy_provider.dart @@ -40,6 +40,8 @@ class OnRamperBuyProvider extends BuyProvider { String? recommendedPaymentType; + FiatCurrency? recommendedFiat; + String get _apiKey => secrets.onramperApiKey; String get _exchangeHelperApiKey => secrets.exchangeHelperApiKey; @@ -76,7 +78,7 @@ class OnRamperBuyProvider extends BuyProvider { } } - Future getRecommendedPaymentType(bool isBuyAction) async { + Future fetchRecommendedDefaults(bool isBuyAction) async { final params = {'type': isBuyAction ? 'buy' : 'sell'}; final url = Uri.https(_baseApiUrl, '$supported$defaultsAll', params); @@ -91,21 +93,26 @@ class OnRamperBuyProvider extends BuyProvider { final Map data = jsonDecode(response.body) as Map; final recommended = data['message']['recommended'] as Map; - final recommendedPaymentType = recommended['paymentMethod'] as String?; + recommendedPaymentType = recommended['paymentMethod'] as String?; - return recommendedPaymentType; + // the recommendation is a source -> target pair, so which side is the + // fiat one flips with the action + final fiat = recommended[isBuyAction ? 'source' : 'target'] as String?; + recommendedFiat = + fiat == null ? null : FiatCurrency.tryDeserialize(raw: fiat.toUpperCase()); } else { final responseBody = jsonDecode(response.body) as Map; - printV('Failed to fetch available payment types: ${responseBody['message']}'); + printV('Failed to fetch recommended defaults: ${responseBody['message']}'); } } catch (e) { - printV('Failed to fetch available payment types: $e'); + printV('Failed to fetch recommended defaults: $e'); } - return null; } Future> getAvailablePaymentTypes( String fiatCurrency, CryptoCurrency cryptoCurrency, bool isBuyAction) async { + await fetchRecommendedDefaults(isBuyAction); + final normalizedCryptoCurrency = cryptoCurrency.title + _getNormalizeNetwork(cryptoCurrency); final sourceCurrency = (isBuyAction ? fiatCurrency : normalizedCryptoCurrency).toLowerCase(); @@ -128,8 +135,6 @@ class OnRamperBuyProvider extends BuyProvider { .map((item) => PaymentMethod.fromOnramperJson(item as Map)) .toList(); - recommendedPaymentType = await getRecommendedPaymentType(isBuyAction); - return allAvailablePaymentMethods; } else { final responseBody = jsonDecode(response.body) as Map; diff --git a/lib/buy/sell_buy_states.dart b/lib/buy/sell_buy_states.dart index 8e6650cb24..6b43361788 100644 --- a/lib/buy/sell_buy_states.dart +++ b/lib/buy/sell_buy_states.dart @@ -17,7 +17,7 @@ class BuySellQuotLoading extends BuySellQuotLoadingState {} class BuySellQuotLoaded extends BuySellQuotLoadingState {} class BuySellQuotFailed extends BuySellQuotLoadingState { - final String? errorMessage; + final String errorMessage; - BuySellQuotFailed({this.errorMessage}); + BuySellQuotFailed({required this.errorMessage}); } diff --git a/lib/entities/fiat_currency.dart b/lib/entities/fiat_currency.dart index 61d9ea4e0f..4333ccdd8c 100644 --- a/lib/entities/fiat_currency.dart +++ b/lib/entities/fiat_currency.dart @@ -270,6 +270,8 @@ class FiatCurrency extends EnumerableItem with Serializable impl static FiatCurrency deserialize({required String raw}) => _all[raw] ?? FiatCurrency.usd; + static FiatCurrency? tryDeserialize({required String raw}) => _all[raw]; + @override bool operator ==(Object other) => other is FiatCurrency && other.raw == raw; diff --git a/lib/new-ui/pages/buy_sell/buy_sell_provider_page.dart b/lib/new-ui/pages/buy_sell/buy_sell_provider_page.dart index a9e5c1e10e..f0d714981a 100644 --- a/lib/new-ui/pages/buy_sell/buy_sell_provider_page.dart +++ b/lib/new-ui/pages/buy_sell/buy_sell_provider_page.dart @@ -1,5 +1,6 @@ import "package:cake_wallet/buy/buy_quote.dart"; import "package:cake_wallet/buy/sell_buy_states.dart"; +import "package:cake_wallet/entities/fiat_currency.dart"; import "package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart"; import "package:cake_wallet/entities/new_ui_entities/list_item/list_item_dropdown.dart"; import "package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart"; @@ -7,6 +8,7 @@ import "package:cake_wallet/generated/i18n.dart"; import "package:cake_wallet/new-ui/pages/buy_sell/buy_sell_confirmation_page.dart"; import "package:cake_wallet/new-ui/pages/buy_sell/buy_sell_payment_method_page.dart"; import "package:cake_wallet/new-ui/widgets/money/money_text.dart"; +import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; import "package:cake_wallet/src/widgets/new_list_row/new_list_section.dart"; import "package:cake_wallet/view_model/buy/buy_sell_view_model.dart"; @@ -52,31 +54,8 @@ class _BuySellProviderPageState extends State { Expanded( child: Observer( builder: (_) { - if (widget.buySellViewModel.buySellQuotState is BuySellQuotFailed) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 24, - children: [ - const Icon(Icons.warning_amber_outlined, size: 48), - Column( - spacing: 10, - children: [ - Text( - S.of(context).could_not_load_quotes, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), - ), - Text( - (widget.buySellViewModel.buySellQuotState as BuySellQuotFailed) - .errorMessage ?? - S.of(context).please_try_again_later, - ), - ], - ), - ], - ); - } - - if (widget.buySellViewModel.buySellQuotState is BuySellQuotLoading) { + if (widget.buySellViewModel.buySellQuotState is BuySellQuotLoading || + widget.buySellViewModel.buySellQuotState is InitialBuySellQuotState) { return Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -93,6 +72,18 @@ class _BuySellProviderPageState extends State { ); } + if (widget.buySellViewModel.buySellQuotState is BuySellQuotFailed) { + final recommendedFiat = widget.buySellViewModel.recommendedFiat; + + return BuySellQuoteFailure( + state: widget.buySellViewModel.buySellQuotState as BuySellQuotFailed, + recommendedFiat: recommendedFiat == widget.buySellViewModel.fiatCurrency + ? null + : recommendedFiat, + onRetryPressed: widget.buySellViewModel.retryWithRecommendedFiat, + ); + } + return SingleChildScrollView( controller: ModalScrollController.of(context), child: Padding( @@ -195,3 +186,54 @@ class _BuySellProviderPageState extends State { ); } } + +class BuySellQuoteFailure extends StatelessWidget { + const BuySellQuoteFailure({ + required this.state, + required this.onRetryPressed, + super.key, + this.recommendedFiat, + }); + + final BuySellQuotFailed state; + final FiatCurrency? recommendedFiat; + final VoidCallback onRetryPressed; + + @override + Widget build(BuildContext context) { + final recommendedFiat = this.recommendedFiat; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 24, + children: [ + const Icon(Icons.warning_amber_outlined, size: 48), + Column( + spacing: 10, + children: [ + Text( + S.of(context).could_not_load_quotes, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + ), + Text( + recommendedFiat == null + ? state.errorMessage + : S.of(context).providers_in_region_use_fiat(recommendedFiat.title), + textAlign: TextAlign.center, + ), + ], + ), + if (recommendedFiat != null) + NewPrimaryButton( + onPressed: onRetryPressed, + text: S.of(context).switch_to_fiat(recommendedFiat.title), + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ], + ), + ); + } +} diff --git a/lib/view_model/buy/buy_sell_view_model.dart b/lib/view_model/buy/buy_sell_view_model.dart index e936ca4bac..f9ae36cada 100644 --- a/lib/view_model/buy/buy_sell_view_model.dart +++ b/lib/view_model/buy/buy_sell_view_model.dart @@ -19,7 +19,6 @@ import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/utils/print_verbose.dart'; import 'package:flutter/cupertino.dart'; import 'package:mobx/mobx.dart'; @@ -27,6 +26,8 @@ part 'buy_sell_view_model.g.dart'; enum BuySellPageMode { buy, sell } +enum _AmountSide { fiat, crypto } + class BuySellViewModel = BuySellViewModelBase with _$BuySellViewModel; abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with Store { @@ -96,20 +97,17 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S // sets based on the absolute amout (from the fiat/charts api) // works even if you have no rates - Future setCryptoAmountFromFiat(String fiatAmount) async { - if(fiatAmount.isEmpty) { - await changeCryptoAmount(amount: ""); - return; - } + @action + Future setCryptoAmountFromFiat(String amount) async { + final enteredAmount = double.tryParse(amount.replaceAll(',', '.')); + final price = fiatConversionStore.prices[cryptoCurrency]; - if(fiatConversionStore.prices[cryptoCurrency] == null) { + if (enteredAmount == null || price == null || price <= 0) { + await changeCryptoAmount(amount: ''); return; } - await changeCryptoAmount( - amount: (double.parse(fiatAmount) / (fiatConversionStore.prices[cryptoCurrency]!)) - .toString(), - ); + await changeCryptoAmount(amount: (enteredAmount / price).toString()); } final AppStore _appStore; @@ -174,6 +172,8 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S @observable bool skipIsReadyToTradeReaction = false; + _AmountSide _enteredSide = _AmountSide.fiat; + @computed String? get maxFiatAmount { if ((sortedQuotes.isEmpty && sortedRecommendedQuotes.isEmpty) || @@ -191,20 +191,41 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S return maxAmount.toStringAsFixed(2); } - Money? amountForQuote(Quote quote) => Money.trySafeParse( - ((double.tryParse(fiatAmount) ?? 0) / quote.rate).toStringAsFixed(min(20, cryptoCurrency.decimals)), - cryptoCurrency); + double? get _enteredFiatAmount => double.tryParse(fiatAmount.replaceAll(',', '.')); - Money? fiatAmountForQuote(Quote quote) { - if(fiatConversionStore.prices[cryptoCurrency] == null) { - return null; + double? get _enteredCryptoAmount => double.tryParse(_cryptoAmount.replaceAll(',', '.')); + + Money? amountForQuote(Quote quote) { + final double? amount; + + if (mode == BuySellPageMode.sell) { + amount = _enteredCryptoAmount; + } else { + final enteredAmount = _enteredFiatAmount; + amount = enteredAmount == null || quote.rate <= 0 ? null : enteredAmount / quote.rate; } + if (amount == null || !amount.isFinite) return null; + return Money.trySafeParse( - (fiatConversionStore.prices[cryptoCurrency]! * - (double.tryParse(amountForQuote(quote).toString())??0)) - .toStringAsFixed(2), - fiatCurrency); + amount.toStringAsFixed(min(20, cryptoCurrency.decimals)), cryptoCurrency); + } + + Money? fiatAmountForQuote(Quote quote) { + final double? amount; + + if (mode == BuySellPageMode.sell) { + final enteredAmount = _enteredCryptoAmount; + amount = enteredAmount == null ? null : enteredAmount * quote.rate; + } else { + final price = fiatConversionStore.prices[cryptoCurrency]; + final cryptoAmount = double.tryParse(amountForQuote(quote)?.toString() ?? ''); + amount = price == null || cryptoAmount == null ? null : price * cryptoAmount; + } + + if (amount == null || !amount.isFinite) return null; + + return Money.trySafeParse(amount.toStringAsFixed(fiatCurrency.decimals), fiatCurrency); } // based on usd values, should have roughly equal worth (was done with ai though so it's subject to correction) @@ -281,6 +302,9 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S isBuySellQuotLoaded; } + FiatCurrency? get recommendedFiat => + providerList.whereType().firstOrNull?.recommendedFiat; + @computed bool get isBuySellQuoteFailed => buySellQuotState is BuySellQuotFailed; @@ -306,6 +330,15 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S _onPairChange(); } + @action + Future retryWithRecommendedFiat() async { + final currency = recommendedFiat; + if (currency == null) return; + + fiatCurrency = currency; + await _initialize(keepEnteredAmount: true); + } + @action void changeCryptoCurrency({required CryptoCurrency currency}) { cryptoCurrency = currency; @@ -324,6 +357,7 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S @action Future changeFiatAmount({required String amount}) async { + _enteredSide = _AmountSide.fiat; fiatAmount = amount.replaceAll(",", "."); if (amount.isEmpty) { @@ -340,20 +374,17 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S return; } - printV(bestRateQuote); - if (bestRateQuote != null) { - final enteredAmount = double.tryParse(fiatAmount.replaceAll(',', '.')) ?? 0; - final amount = enteredAmount / bestRateQuote!.rate; - printV(amount); - - _cryptoAmount = amount.toString().withMaxDecimals(cryptoCurrency.decimals); - } else { + if (_quoteForAmounts == null) { await calculateBestRate(); + return; } + + _recalculateDerivedAmount(); } @action Future changeCryptoAmount({required String amount}) async { + _enteredSide = _AmountSide.crypto; _cryptoAmount = _appStore.amountParsingProxy.getCanonicalCryptoAmount(amount, cryptoCurrency); if (amount.isEmpty) { @@ -370,14 +401,12 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S return; } - if (bestRateQuote != null) { - final enteredAmount = double.tryParse(_cryptoAmount.replaceAll(',', '.')) ?? 0; - - fiatAmount = - (enteredAmount * bestRateQuote!.rate).toString().withMaxDecimals(fiatCurrency.decimals); - } else { + if (_quoteForAmounts == null) { await calculateBestRate(); + return; } + + _recalculateDerivedAmount(); } @action @@ -387,6 +416,7 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S sortedQuotes.forEach((element) => element.setIsSelected = false); option.setIsSelected = true; selectedQuote = option; + _recalculateDerivedAmount(); } else if (option is PaymentMethod) { paymentMethods.forEach((element) => element.isSelected = false); option.isSelected = true; @@ -461,10 +491,18 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S void _setProviders() => providerList = mode == BuySellPageMode.buy ? availableBuyProviders : availableSellProviders; - Future _initialize() async { + Future _initialize({bool keepEnteredAmount = false}) async { _setProviders(); - _cryptoAmount = ''; - fiatAmount = ''; + + if (!keepEnteredAmount) { + _cryptoAmount = ""; + fiatAmount = ""; + } else if (_enteredSide == _AmountSide.fiat) { + _cryptoAmount = ""; + } else { + fiatAmount = ""; + } + cryptoCurrencyAddress = _getInitialCryptoCurrencyAddress(); paymentMethodState = InitialPaymentMethod(); buySellQuotState = InitialBuySellQuotState(); @@ -627,9 +665,30 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S sortedRecommendedQuotes.first.setIsSelected = true; } + _recalculateDerivedAmount(); + buySellQuotState = BuySellQuotLoaded(); } + Quote? get _quoteForAmounts => selectedQuote ?? bestRateQuote; + + @action + void _recalculateDerivedAmount() { + final rate = _quoteForAmounts?.rate; + if (rate == null || rate <= 0) return; + + final enteredFiat = _enteredFiatAmount; + final enteredCrypto = _enteredCryptoAmount; + + if (_enteredSide == _AmountSide.fiat && enteredFiat != null) { + _cryptoAmount = (enteredFiat / rate).toString().withMaxDecimals(cryptoCurrency.decimals); + } else if (enteredCrypto != null) { + fiatAmount = (enteredCrypto * rate).toString().withMaxDecimals(fiatCurrency.decimals); + } else if (enteredFiat != null) { + _cryptoAmount = (enteredFiat / rate).toString().withMaxDecimals(cryptoCurrency.decimals); + } + } + @action Future launchTrade(BuildContext context) async { final provider = selectedQuote!.provider; diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index ffe5f1fde5..52c3acb7f9 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -871,6 +871,7 @@ "provider": "Provider", "provider_error": "${provider} error", "providers": "Providers", + "providers_in_region_use_fiat": "Providers in your region usually use ${fiat}. Try with ${fiat}?", "public_key": "Public key", "purchase_gift_card": "Purchase Gift Card", "purchase_with": "Purchase with", @@ -1220,6 +1221,7 @@ "swipe_to_send": "Swipe to send", "switch_camera": "Switch camera", "switch_input_currency": "Switch between crypto and fiat entry", + "switch_to_fiat": "Switch to ${fiat}", "switch_to_x_wallet": "Switch to ${network} Wallet", "switch_wallet": "Switch Wallet", "switchToETHWallet": "Please switch to an Ethereum wallet and try again",