import 'dart:async'; import 'dart:math'; import 'package:hash_wallet/buy/buy_provider.dart'; import 'package:hash_wallet/buy/buy_quote.dart'; import 'package:hash_wallet/buy/payment_method.dart'; import 'package:hash_wallet/buy/sell_buy_states.dart'; import 'package:hash_wallet/core/amount_parsing_proxy.dart'; import 'package:hash_wallet/core/selectable_option.dart'; import 'package:hash_wallet/core/wallet_change_listener_view_model.dart'; import 'package:hash_wallet/entities/fiat_currency.dart'; import 'package:hash_wallet/entities/provider_types.dart'; import 'package:hash_wallet/generated/i18n.dart'; import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart"; import 'package:hash_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart'; import 'package:hash_wallet/routes.dart'; import 'package:hash_wallet/store/app_store.dart'; import 'package:hash_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'; part 'buy_sell_view_model.g.dart'; enum BuySellPageMode { buy, sell } class BuySellViewModel = BuySellViewModelBase with _$BuySellViewModel; abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with Store { BuySellViewModelBase(AppStore appStore, {required NewBuySellParams params, required this.fiatConversionStore}) : _cryptoAmount = '', fiatAmount = '', mode = params.mode, cryptoCurrencyAddress = '', isCryptoCurrencyAddressEnabled = false, cryptoCurrencies = [], fiatCurrencies = [], paymentMethodState = InitialPaymentMethod(), buySellQuotState = InitialBuySellQuotState(), cryptoCurrency = appStore.wallet!.currency, fiatCurrency = appStore.settingsStore.fiatCurrency, providerList = [], sortedRecommendedQuotes = ObservableList(), sortedQuotes = ObservableList(), paymentMethods = ObservableList(), _appStore = appStore, super(appStore: appStore) { const excludeFiatCurrencies = []; const excludeCryptoCurrencies = []; fiatCurrencies = FiatCurrency.all.where((currency) => !excludeFiatCurrencies.contains(currency)).toList(); cryptoCurrencies = CryptoCurrency.all .where((currency) => !excludeCryptoCurrencies.contains(currency)) .toList(); _initialize(); isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency); if(params.initialCurrency != null) { changeCryptoCurrency(currency: params.initialCurrency!); } } late Timer bestRateSync; final FiatConversionStore fiatConversionStore; List get availableBuyProviders { final providerTypes = ProvidersHelper.getAvailableBuyProviderTypes(); return providerTypes .map((type) => ProvidersHelper.getProviderByType(type)) .cast() .toList(); } List get availableSellProviders { final providerTypes = ProvidersHelper.getAvailableSellProviderTypes(); return providerTypes .map((type) => ProvidersHelper.getProviderByType(type)) .cast() .toList(); } @override void onWalletChange(wallet) { cryptoCurrency = wallet.currency; } double get amount { final formattedFiatAmount = double.tryParse(fiatAmount); final formattedCryptoAmount = double.tryParse(_cryptoAmount); return mode == BuySellPageMode.buy ? formattedFiatAmount ?? 200.0 : formattedCryptoAmount ?? (cryptoCurrency == CryptoCurrency.btc ? 0.01 : 1); } // 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; } if(fiatConversionStore.prices[cryptoCurrency] == null) { return; } await changeCryptoAmount( amount: (double.parse(fiatAmount) / (fiatConversionStore.prices[cryptoCurrency]!)) .toString(), ); } final AppStore _appStore; AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy; Quote? bestRateQuote; Quote? selectedQuote; @observable List cryptoCurrencies; @observable List fiatCurrencies; final BuySellPageMode mode; @observable List providerList; @observable ObservableList sortedRecommendedQuotes; @observable ObservableList sortedQuotes; @observable ObservableList paymentMethods; @observable FiatCurrency fiatCurrency; @observable CryptoCurrency cryptoCurrency; @observable String _cryptoAmount; @computed String get cryptoAmount => _appStore.amountParsingProxy.getDisplayCryptoAmount(_cryptoAmount, cryptoCurrency); @observable String fiatAmount; @observable String cryptoCurrencyAddress; @observable bool isCryptoCurrencyAddressEnabled; @observable PaymentMethod? selectedPaymentMethod; @observable PaymentMethodLoadingState paymentMethodState; @observable BuySellQuotLoadingState buySellQuotState; @observable bool skipIsReadyToTradeReaction = false; @computed String? get maxFiatAmount { if ((sortedQuotes.isEmpty && sortedRecommendedQuotes.isEmpty) || buySellQuotState is! BuySellQuotLoaded) { return null; } final allQuotes = sortedRecommendedQuotes.followedBy(sortedQuotes); final maxAmount = allQuotes.fold(0.0, (current, item) { final limitMax = item.limits?.max?.toDouble() ?? 0.0; return max(current, limitMax); }); return maxAmount.toStringAsFixed(2); } Money? amountForQuote(Quote quote) => Money.trySafeParse( ((double.tryParse(fiatAmount) ?? 0) / quote.rate).toStringAsFixed(min(20, cryptoCurrency.decimals)), cryptoCurrency); Money? fiatAmountForQuote(Quote quote) { if(fiatConversionStore.prices[cryptoCurrency] == null) { return null; } return Money.trySafeParse( (fiatConversionStore.prices[cryptoCurrency]! * (double.tryParse(amountForQuote(quote).toString())??0)) .toStringAsFixed(2), fiatCurrency); } // based on usd values, should have roughly equal worth (was done with ai though so it's subject to correction) static final Map> _defaultAmountsMap = { FiatCurrency.amd: ["20000", "40000", "200000", "400000", "1000000"], FiatCurrency.ars: ["50000", "100000", "500000", "1000000", "2500000"], FiatCurrency.aud: ["100", "200", "1000", "2000", "5000"], FiatCurrency.bdt: ["5000", "10000", "50000", "100000", "250000"], FiatCurrency.bgn: ["100", "200", "1000", "2000", "5000"], FiatCurrency.brl: ["250", "500", "2500", "5000", "12500"], FiatCurrency.cad: ["50", "100", "500", "1000", "2500"], FiatCurrency.chf: ["50", "100", "500", "1000", "2500"], FiatCurrency.clp: ["50000", "100000", "500000", "1000000", "2500000"], FiatCurrency.cny: ["500", "1000", "5000", "10000", "25000"], FiatCurrency.cop: ["200000", "400000", "2000000", "4000000", "10000000"], FiatCurrency.czk: ["1000", "2000", "10000", "20000", "50000"], FiatCurrency.dkk: ["400", "800", "4000", "8000", "20000"], FiatCurrency.egp: ["2500", "5000", "25000", "50000", "125000"], FiatCurrency.eur: ["50", "100", "500", "1000", "2500"], FiatCurrency.gbp: ["50", "100", "500", "1000", "2500"], FiatCurrency.ghs: ["500", "1000", "5000", "10000", "25000"], FiatCurrency.gtq: ["400", "800", "4000", "8000", "20000"], FiatCurrency.hkd: ["400", "800", "4000", "8000", "20000"], FiatCurrency.hrk: ["400", "800", "4000", "8000", "20000"], FiatCurrency.huf: ["20000", "40000", "200000", "400000", "1000000"], FiatCurrency.idr: ["800000", "1600000", "8000000", "16000000", "40000000"], FiatCurrency.ils: ["200", "400", "2000", "4000", "10000"], FiatCurrency.inr: ["5000", "10000", "50000", "100000", "250000"], FiatCurrency.irr: ["2000000", "4000000", "20000000", "40000000", "100000000"], FiatCurrency.isk: ["7000", "14000", "70000", "140000", "350000"], FiatCurrency.jpy: ["10000", "20000", "100000", "200000", "500000"], FiatCurrency.kes: ["5000", "10000", "50000", "100000", "250000"], FiatCurrency.krw: ["50000", "100000", "500000", "1000000", "2500000"], FiatCurrency.mad: ["500", "1000", "5000", "10000", "25000"], FiatCurrency.mxn: ["1000", "2000", "10000", "20000", "50000"], FiatCurrency.myr: ["250", "500", "2500", "5000", "12500"], FiatCurrency.ngn: ["50000", "100000", "500000", "1000000", "2500000"], FiatCurrency.nok: ["500", "1000", "5000", "10000", "25000"], FiatCurrency.nzd: ["100", "200", "1000", "2000", "5000"], FiatCurrency.php: ["3000", "6000", "30000", "60000", "150000"], FiatCurrency.pkr: ["15000", "30000", "150000", "300000", "750000"], FiatCurrency.pln: ["200", "400", "2000", "4000", "10000"], FiatCurrency.ron: ["250", "500", "2500", "5000", "12500"], FiatCurrency.rub: ["5000", "10000", "50000", "100000", "250000"], FiatCurrency.sar: ["200", "400", "2000", "4000", "10000"], FiatCurrency.sek: ["500", "1000", "5000", "10000", "25000"], FiatCurrency.sgd: ["50", "100", "500", "1000", "2500"], FiatCurrency.thb: ["2000", "4000", "20000", "40000", "100000"], FiatCurrency.tur: ["1500", "3000", "15000", "30000", "75000"], FiatCurrency.twd: ["1500", "3000", "15000", "30000", "75000"], FiatCurrency.uah: ["2000", "4000", "20000", "40000", "100000"], FiatCurrency.usd: ["50", "100", "500", "1000", "2500"], FiatCurrency.vef: ["2000", "4000", "20000", "40000", "100000"], FiatCurrency.vnd: ["1000000", "2000000", "10000000", "20000000", "50000000"], FiatCurrency.zar: ["1000", "2000", "10000", "20000", "50000"], }; // the fallback is just the usd values. // not great but this fallback shouldn't be triggered anyway List get defaultAmounts => _defaultAmountsMap[fiatCurrency] ?? ["50", "100", "500", "1000", "2500"]; @computed bool get isReadyToTrade { final hasSelectedQuote = selectedQuote != null; final hasSelectedPaymentMethod = selectedPaymentMethod != null; final isPaymentMethodLoaded = paymentMethodState is PaymentMethodLoaded; final isBuySellQuotLoaded = buySellQuotState is BuySellQuotLoaded; return hasSelectedQuote && hasSelectedPaymentMethod && isPaymentMethodLoaded && isBuySellQuotLoaded; } @computed bool get isBuySellQuoteFailed => buySellQuotState is BuySellQuotFailed; @computed String? get buySellQuoteFailedError => buySellQuotState is BuySellQuotFailed ? (buySellQuotState as BuySellQuotFailed).errorMessage : null; @computed bool get useSatoshi => _appStore.amountParsingProxy.useSatoshi(cryptoCurrency); @action void reset() { cryptoCurrency = wallet.currency; fiatCurrency = _appStore.settingsStore.fiatCurrency; isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency); _initialize(); } @action void changeFiatCurrency({required FiatCurrency currency}) { fiatCurrency = currency; _onPairChange(); } @action void changeCryptoCurrency({required CryptoCurrency currency}) { cryptoCurrency = currency; _onPairChange(); isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency); } @computed Iterable get activeWalletCurrencies => wallet.balance.keys; @computed bool get hasMultipleCurrencies => activeWalletCurrencies.length > 1; @action void changeCryptoCurrencyAddress(String address) => cryptoCurrencyAddress = address; @action Future changeFiatAmount({required String amount}) async { fiatAmount = amount.replaceAll(",", "."); if (amount.isEmpty) { fiatAmount = ''; _cryptoAmount = ''; return; } if (!isReadyToTrade && !isBuySellQuoteFailed) { _cryptoAmount = "..."; return; } else if (isBuySellQuoteFailed) { _cryptoAmount = ''; 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 { await calculateBestRate(); } } @action Future changeCryptoAmount({required String amount}) async { _cryptoAmount = _appStore.amountParsingProxy.getCanonicalCryptoAmount(amount, cryptoCurrency); if (amount.isEmpty) { fiatAmount = ''; _cryptoAmount = ''; return; } if (!isReadyToTrade && !isBuySellQuoteFailed) { fiatAmount = S.current.fetching; return; } else if (isBuySellQuoteFailed) { fiatAmount = ''; return; } if (bestRateQuote != null) { final enteredAmount = double.tryParse(_cryptoAmount.replaceAll(',', '.')) ?? 0; fiatAmount = (enteredAmount * bestRateQuote!.rate).toString().withMaxDecimals(fiatCurrency.decimals); } else { await calculateBestRate(); } } @action void changeOption(SelectableOption option) { if (option is Quote) { sortedRecommendedQuotes.forEach((element) => element.setIsSelected = false); sortedQuotes.forEach((element) => element.setIsSelected = false); option.setIsSelected = true; selectedQuote = option; } else if (option is PaymentMethod) { paymentMethods.forEach((element) => element.isSelected = false); option.isSelected = true; selectedPaymentMethod = option; } else { throw ArgumentError('Unknown option type'); } } void _onPairChange() { _initialize(); } void _setProviders() => providerList = mode == BuySellPageMode.buy ? availableBuyProviders : availableSellProviders; Future _initialize() async { _setProviders(); _cryptoAmount = ''; fiatAmount = ''; cryptoCurrencyAddress = _getInitialCryptoCurrencyAddress(); paymentMethodState = InitialPaymentMethod(); buySellQuotState = InitialBuySellQuotState(); sortedRecommendedQuotes.clear(); sortedQuotes.clear(); paymentMethods.clear(); await _getAvailablePaymentTypes(); await calculateBestRate(); } String _getInitialCryptoCurrencyAddress() => wallet.walletAddresses.addressForBuy; @action Future _getAvailablePaymentTypes() async { try{ paymentMethodState = PaymentMethodLoading(); selectedPaymentMethod = null; final result = await Future.wait(providerList.map((element) => element .getAvailablePaymentTypes(fiatCurrency.title, cryptoCurrency, mode == BuySellPageMode.buy) .timeout( Duration(seconds: 10), onTimeout: () => [], ))); final List tempPaymentMethods = []; for (var methods in result) { for (var method in methods) { final alreadyExists = tempPaymentMethods.any((m) { if (m.paymentMethodType != method.paymentMethodType) return false; if (method.paymentMethodType != PaymentType.unknown) return true; return m.customTitle == method.customTitle; }); if (!alreadyExists) { tempPaymentMethods.add(method); } } } paymentMethods = ObservableList.of(tempPaymentMethods); if (paymentMethods.isNotEmpty) { paymentMethods.insert(0, PaymentMethod.all()); selectedPaymentMethod = paymentMethods.first; selectedPaymentMethod!.isSelected = true; paymentMethodState = PaymentMethodLoaded(); } else { paymentMethodState = PaymentMethodFailed(); } } catch(e) { paymentMethodState = PaymentMethodFailed(); } } @action Future calculateBestRate() async { buySellQuotState = BuySellQuotLoading(); final List validProviders = providerList.where((provider) { if (mode == BuySellPageMode.buy) { return provider.supportedCryptoList.any((pair) => pair.from.symbol == cryptoCurrency.symbol && pair.from.tag == cryptoCurrency.tag && pair.to.symbol == fiatCurrency.symbol); } else { return provider.supportedFiatList.any((pair) => pair.from.symbol == fiatCurrency.symbol && pair.to.symbol == cryptoCurrency.symbol && pair.to.tag == cryptoCurrency.tag); } }).toList(); if (validProviders.isEmpty) { buySellQuotState = BuySellQuotFailed( errorMessage: S.current.no_buy_providers(cryptoCurrency.fullName??cryptoCurrency.title)); return; } final result = await Future.wait?>(validProviders.map((element) => element .fetchQuote( cryptoCurrency: cryptoCurrency, fiatCurrency: fiatCurrency, amount: amount, paymentType: selectedPaymentMethod?.paymentMethodType, isBuyAction: mode == BuySellPageMode.buy, walletAddress: wallet.walletAddresses.address, customPaymentMethodType: selectedPaymentMethod?.customPaymentMethodType, ) .timeout( Duration(seconds: 10), onTimeout: () => null, ))); sortedRecommendedQuotes.clear(); sortedQuotes.clear(); final validQuotes = result .where((element) => element != null && element.isNotEmpty) .expand((element) => element!) .toList(); if (validQuotes.isEmpty) { buySellQuotState = BuySellQuotFailed( errorMessage: S.current.no_buy_providers(cryptoCurrency.fullName??cryptoCurrency.title)); return; } if (mode == BuySellPageMode.buy) { validQuotes.sort((a, b) => b.payout.compareTo(a.payout)); } else { validQuotes.sort((a, b) => a.payout.compareTo(b.payout)); } final Set addedProviders = {}; final List uniqueProviderQuotes = validQuotes.where((element) { if (addedProviders.contains(element.provider.title)) return false; addedProviders.add(element.provider.title); return true; }).toList(); sortedRecommendedQuotes.addAll(uniqueProviderQuotes); sortedQuotes = ObservableList.of( validQuotes.where((element) => !uniqueProviderQuotes.contains(element)).toList()); if (sortedRecommendedQuotes.isNotEmpty) { sortedRecommendedQuotes.first..setIsBestRate = true; bestRateQuote = sortedRecommendedQuotes.first; final Quote effectiveBestRateQuote = sortedRecommendedQuotes.reduce((a, b) { return mode == BuySellPageMode.buy ? a.rate < b.rate ? a : b : a.rate > b.rate ? a : b; }); effectiveBestRateQuote.recommendations.add(ProviderRecommendation.bestRate); selectedQuote = sortedRecommendedQuotes.first; sortedRecommendedQuotes.first.setIsSelected = true; } buySellQuotState = BuySellQuotLoaded(); } @action Future launchTrade(BuildContext context) async { final provider = selectedQuote!.provider; try { await provider.launchProvider( context: context, quote: selectedQuote!, amount: amount, isBuyAction: mode == BuySellPageMode.buy, cryptoCurrencyAddress: cryptoCurrencyAddress, ); } catch (e) { if (e.toString().contains("403")) { buySellQuotState = BuySellQuotFailed(errorMessage: "Using Tor is not supported"); } else { buySellQuotState = BuySellQuotFailed(errorMessage: "Something went wrong please try again later"); } } } }