diff --git a/lib/di.dart b/lib/di.dart index 006e1a753..cf82add32 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -43,6 +43,9 @@ import 'package:cake_wallet/src/screens/start_tor/start_tor_page.dart'; import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart'; import 'package:cake_wallet/src/screens/wallet_connect/services/key_service/wallet_connect_key_service.dart'; import 'package:cake_wallet/src/screens/wallet_connect/services/walletkit_service.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/swap_details_bottom_sheet.dart'; + import 'package:cake_wallet/store/dashboard/order_filter_store.dart'; import 'package:cake_wallet/themes/core/theme_store.dart'; import 'package:cake_wallet/view_model/dev/monero_background_sync.dart'; @@ -530,6 +533,7 @@ Future setup({ getIt.get(), getIt.get(), _walletInfoSource, + getIt.get(), ), ); @@ -1113,6 +1117,7 @@ Future setup({ tradesStore: getIt.get(), sendViewModel: getIt.get(), feesViewModel: getIt.get(), + fiatConversionStore: getIt.get(), ), ); @@ -1133,6 +1138,22 @@ Future setup({ getIt.registerFactory(() => ExchangeTemplatePage(getIt.get())); + getIt.registerFactoryParam( + (paymentFlowResult, _) => SwapConfirmationBottomSheet( + paymentFlowResult: paymentFlowResult, + currentTheme: getIt.get().themeStore.currentTheme, + exchangeViewModel: getIt.get(), + authService: getIt.get(), + ), + ); + + getIt.registerFactory( + () => SwapDetailsBottomSheet( + currentTheme: getIt.get().themeStore.currentTheme, + exchangeTradeViewModel: getIt.get(), + ), + ); + getIt.registerFactory(() => PaymentViewModel( appStore: getIt.get(), walletInfoSource: _walletInfoSource, diff --git a/lib/src/screens/exchange/exchange_page.dart b/lib/src/screens/exchange/exchange_page.dart index 172b210c4..db0114a87 100644 --- a/lib/src/screens/exchange/exchange_page.dart +++ b/lib/src/screens/exchange/exchange_page.dart @@ -10,6 +10,7 @@ import 'package:cake_wallet/utils/debounce.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; @@ -129,8 +130,7 @@ class ExchangePage extends BasePage { color: Theme.of(context).colorScheme.primary, size: 16, ); - final _closeButton = - currentTheme.isDark ? closeButtonImageDarkTheme : closeButtonImage; + final _closeButton = currentTheme.isDark ? closeButtonImageDarkTheme : closeButtonImage; bool isMobileView = responsiveLayoutUtil.shouldRenderMobileUI; @@ -368,10 +368,13 @@ class ExchangePage extends BasePage { void applyTemplate( BuildContext context, ExchangeViewModel exchangeViewModel, ExchangeTemplate template) async { - final depositCryptoCurrency = CryptoCurrency.safeParseCurrencyFromString(template.depositCurrency); - final receiveCryptoCurrency = CryptoCurrency.safeParseCurrencyFromString(template.receiveCurrency); + final depositCryptoCurrency = + CryptoCurrency.safeParseCurrencyFromString(template.depositCurrency); + final receiveCryptoCurrency = + CryptoCurrency.safeParseCurrencyFromString(template.receiveCurrency); - if (depositCryptoCurrency == null || receiveCryptoCurrency == null) { ///TO DO: add support for user tokens + if (depositCryptoCurrency == null || receiveCryptoCurrency == null) { + ///TO DO: add support for user tokens return; } @@ -610,9 +613,15 @@ class ExchangePage extends BasePage { }); if (initialPaymentRequest != null) { - exchangeViewModel.receiveCurrency = CryptoCurrency.fromString(initialPaymentRequest!.scheme); - exchangeViewModel.depositAmount = initialPaymentRequest!.amount; - exchangeViewModel.receiveAddress = initialPaymentRequest!.address; + try { + exchangeViewModel.receiveCurrency = + CryptoCurrency.fromString(initialPaymentRequest!.scheme); + exchangeViewModel.receiveAmount = initialPaymentRequest!.amount; + exchangeViewModel.receiveAddress = initialPaymentRequest!.address; + } catch (e) { + printV('error: ${e.toString()}'); + // TODO + } } _isReactionsSet = true; diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 3e58f4c95..836e80fa1 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -1,17 +1,22 @@ +import 'dart:async'; + import 'package:cake_wallet/core/open_crypto_pay/open_cryptopay_service.dart'; import 'package:cake_wallet/entities/priority_for_wallet_type.dart'; import 'package:cake_wallet/src/screens/receive/widgets/currency_input_field.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/wallet_switcher_bottom_sheet.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart'; import 'package:cake_wallet/src/widgets/picker.dart'; import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; + import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; +import 'package:cake_wallet/exchange/trade.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency.dart'; import 'package:cake_wallet/routes.dart'; @@ -29,6 +34,7 @@ import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/src/widgets/address_text_field.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; +import 'package:cake_wallet/di.dart'; class SendCard extends StatefulWidget { SendCard({ @@ -100,6 +106,7 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: S.of(context).error, - alertContent: S.of(context).unmatched_currencies, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop(), - ); - }, - ); + final separator = initialPaymentRequest!.scheme.isNotEmpty ? ":" : ""; + final uri = initialPaymentRequest!.scheme + separator + initialPaymentRequest!.address; + _handlePaymentFlow(uri, initialPaymentRequest!); } }, ); @@ -145,9 +143,6 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin _handlePaymentFlow(String uri, PaymentRequest paymentRequest) async { try { - // TODO: just remove this line - throw "Pay Anything is temporarily disabled"; - final result = await paymentViewModel.processAddress(uri); switch (result.type) { @@ -201,7 +196,7 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin Navigator.of(context).pushNamed(Routes.exchange, arguments: paymentRequest), + onSwap: () => _handleSwapFlow(paymentViewModel, result), ); }, ); @@ -246,6 +241,25 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin( + context: context, + isDismissible: false, + builder: (BuildContext context) { + loadingBottomSheetContext = context; + return LoadingBottomSheet( + titleText: S.of(context).loading_your_wallet, + ); + }, + ); + } + }); + await Future.delayed(const Duration(seconds: 2)); + if (loadingBottomSheetContext != null && + loadingBottomSheetContext!.mounted) { + Navigator.of(loadingBottomSheetContext!).pop(); + } _applyPaymentRequest(paymentRequest); } } @@ -261,6 +275,17 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin _handleSwapFlow(PaymentViewModel paymentViewModel, PaymentFlowResult result) async { + Navigator.of(context).pop(); + final bottomSheet = getIt.get(param1: result); + await showModalBottomSheet( + context: context, + isDismissible: true, + isScrollControlled: true, + builder: (BuildContext context) => bottomSheet, + ); + } + @override Widget build(BuildContext context) { super.build(context); diff --git a/lib/src/widgets/base_text_form_field.dart b/lib/src/widgets/base_text_form_field.dart index b51ff200b..05baed589 100644 --- a/lib/src/widgets/base_text_form_field.dart +++ b/lib/src/widgets/base_text_form_field.dart @@ -124,7 +124,7 @@ class BaseTextFormField extends StatelessWidget { isDense: isDense, alignLabelWithHint: alignLabelWithHint, contentPadding: contentPadding, - floatingLabelBehavior: FloatingLabelBehavior.never, + floatingLabelBehavior: floatingLabelBehavior ?? FloatingLabelBehavior.never, prefixIconConstraints: prefixIconConstraints ?? const BoxConstraints(minWidth: 0, minHeight: 0), prefix: prefix, diff --git a/lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart new file mode 100644 index 000000000..6ac00c5e3 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart @@ -0,0 +1,554 @@ +import 'dart:developer'; + +import 'package:cake_wallet/core/amount_validator.dart'; +import 'package:cake_wallet/core/auth_service.dart'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/exchange/limits_state.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker.dart'; +import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/swap_details_bottom_sheet.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/debounce.dart'; + +import 'package:flutter/material.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; +import 'package:cake_wallet/view_model/exchange/exchange_view_model.dart'; +import 'package:cake_wallet/exchange/exchange_trade_state.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:mobx/mobx.dart'; + +class SwapConfirmationBottomSheet extends BaseBottomSheet { + SwapConfirmationBottomSheet({ + Key? key, + required this.paymentFlowResult, + required this.currentTheme, + required this.exchangeViewModel, + required this.authService, + this.sessionId, + }) : super( + titleText: S.current.swap, + footerType: FooterType.none, + maxHeight: 900, + currentTheme: currentTheme, + ); + + final PaymentFlowResult paymentFlowResult; + final MaterialThemeBase currentTheme; + final ExchangeViewModel exchangeViewModel; + final AuthService authService; + final String? sessionId; + @override + Widget contentWidget(BuildContext context) { + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SwapConfirmationContent( + paymentFlowResult: paymentFlowResult, + exchangeViewModel: exchangeViewModel, + authService: authService, + ), + ); + } +} + +class SwapConfirmationContent extends StatefulWidget { + const SwapConfirmationContent({ + Key? key, + required this.paymentFlowResult, + required this.exchangeViewModel, + required this.authService, + }) : super(key: key); + + final PaymentFlowResult paymentFlowResult; + final ExchangeViewModel exchangeViewModel; + final AuthService authService; + + @override + SwapConfirmationContentState createState() => SwapConfirmationContentState(); +} + +class SwapConfirmationContentState extends State { + late TextEditingController _amountController; + late TextEditingController _amountFiatController; + late TextEditingController _addressController; + late TextEditingController _noteController; + + final _receiveAmountDebounce = Debounce(Duration(milliseconds: 500)); + final _receiveAmountFiatDebounce = Debounce(Duration(milliseconds: 500)); + final FocusNode _amountFocus = FocusNode(); + final FocusNode _amountFiatFocus = FocusNode(); + final FocusNode _addressFocus = FocusNode(); + final FocusNode _noteFocus = FocusNode(); + final _formKey = GlobalKey(); + + ReactionDisposer? _receiveAmountReaction; + ReactionDisposer? _receiveAddressReaction; + ReactionDisposer? _tradeStateReaction; + ReactionDisposer? _bestRateReaction; + ReactionDisposer? _receiveAmountFiatReaction; + + bool _showingFailureDialog = false; + bool _showingSwapDetailsDialog = false; + + @override + void initState() { + super.initState(); + _addressController = + TextEditingController(text: widget.paymentFlowResult.addressDetectionResult?.address ?? ''); + _amountController = TextEditingController( + text: widget.paymentFlowResult.addressDetectionResult?.amount?.isNotEmpty ?? false + ? widget.paymentFlowResult.addressDetectionResult?.amount + : '0.00'); + _amountFiatController = + TextEditingController(text: widget.exchangeViewModel.receiveAmountFiatFormatted); + _noteController = + TextEditingController(text: widget.paymentFlowResult.addressDetectionResult?.note ?? ''); + + WidgetsBinding.instance.addPostFrameCallback( + (_) => _setUpReactions( + context, + widget.exchangeViewModel, + widget.paymentFlowResult, + ), + ); + } + + @override + void dispose() { + _amountController.dispose(); + _amountFiatController.dispose(); + _addressController.dispose(); + _noteController.dispose(); + _amountFocus.dispose(); + _amountFiatFocus.dispose(); + _addressFocus.dispose(); + _noteFocus.dispose(); + _receiveAmountReaction?.call(); + _receiveAddressReaction?.call(); + _tradeStateReaction?.call(); + _bestRateReaction?.call(); + _receiveAmountFiatReaction?.call(); + _showingFailureDialog = false; + _showingSwapDetailsDialog = false; + widget.exchangeViewModel.bestRateSync.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final detectedCurrencyName = walletTypeToCryptoCurrency(widget.paymentFlowResult.walletType!); + + return Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CakeImageWidget( + imageUrl: widget.exchangeViewModel.depositCurrency.iconPath!, + width: 32, + height: 32, + ), + const SizedBox(width: 12), + Icon(Icons.arrow_forward, size: 24), + const SizedBox(width: 12), + CakeImageWidget( + imageUrl: + walletTypeToCryptoCurrency(widget.paymentFlowResult.walletType!).iconPath!, + width: 32, + height: 32, + ), + ], + ), + const SizedBox(height: 16), + SwapConfirmationTextfield( + key: ValueKey('swap_confirmation_bottomsheet_amount_textfield_key'), + hintText: 'Amount (${detectedCurrencyName})', + focusNode: _amountFocus, + controller: _amountController, + validator: (value) { + return AmountValidator( + isAutovalidate: true, + currency: widget.exchangeViewModel.receiveCurrency, + minValue: widget.exchangeViewModel.limits.min.toString(), + maxValue: widget.exchangeViewModel.limits.max.toString(), + ).call(value); + }, + ), + Observer( + builder: (_) { + String? min = '0.0'; + String? max = '0.0'; + + final limitsState = widget.exchangeViewModel.limitsState; + if (limitsState is LimitsLoadedSuccessfully) { + min = limitsState.limits.min?.toString(); + max = limitsState.limits.max?.toString(); + } + + if (limitsState is LimitsLoadedFailure) { + min = '0.0'; + max = '0.0'; + } + + if (limitsState is LimitsIsLoading) { + min = '...'; + max = '...'; + } + if (min != null || max != null) { + return Container( + height: 15, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + min != null + ? Text( + key: ValueKey('min_limit_text_key'), + S.of(context).min_value(min, detectedCurrencyName.toString()), + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 10, + height: 1.2, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + : Offstage(), + min != null ? SizedBox(width: 10) : Offstage(), + max != null + ? Text( + key: ValueKey('max_limit_text_key'), + S.of(context).max_value(max, detectedCurrencyName.toString()), + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 10, + height: 1.2, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + : Offstage(), + ], + ), + ); + } + + return SizedBox.shrink(); + }, + ), + const SizedBox(height: 8), + SwapConfirmationTextfield( + key: ValueKey('swap_confirmation_bottomsheet_amount_fiat_textfield_key'), + hintText: 'Amount (${widget.exchangeViewModel.fiat.title})', + focusNode: _amountFiatFocus, + controller: _amountFiatController, + ), + const SizedBox(height: 8), + SwapConfirmationTextfield( + key: ValueKey('swap_confirmation_bottomsheet_address_textfield_key'), + isAddress: true, + walletType: cryptoCurrencyToWalletType(widget.exchangeViewModel.receiveCurrency), + hintText: 'Destination Address', + focusNode: _addressFocus, + controller: _addressController, + ), + const SizedBox(height: 8), + SwapConfirmationTextfield( + maxLines: 1, + key: ValueKey('swap_confirmation_bottomsheet_note_textfield_key'), + hintText: 'Transaction Note', + focusNode: _noteFocus, + controller: _noteController, + ), + SizedBox(height: 8), + Center( + child: Text( + 'Tap field to edit values', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + SizedBox(height: 32), + SwapConfirmationFooter( + exchangeViewModel: widget.exchangeViewModel, + formKey: _formKey, + authService: widget.authService, + ), + ], + ), + ), + ); + } + + void _setUpReactions( + BuildContext context, + ExchangeViewModel exchangeViewModel, + PaymentFlowResult paymentFlowResult, + ) async { + _receiveAmountReaction = reaction((_) => exchangeViewModel.receiveAmount, (String amount) { + if (_amountController.text != amount) { + _amountController.text = amount; + } + }); + + _receiveAmountFiatReaction = + reaction((_) => exchangeViewModel.receiveAmountFiatFormatted, (String amount) { + if (_amountFiatController.text != amount) { + _amountFiatController.text = amount; + } + }); + + _receiveAddressReaction = reaction((_) => exchangeViewModel.receiveAddress, (String address) { + if (_addressController.text != address) { + _addressController.text = address; + } + }); + + _tradeStateReaction = reaction((_) => exchangeViewModel.tradeState, (ExchangeTradeState state) { + if (state is TradeIsCreatedFailure && !_showingFailureDialog) { + _showingFailureDialog = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + key: const ValueKey('swap_confirmation_trade_creation_failure_dialog_key'), + buttonKey: + const ValueKey('swap_confirmation_trade_creation_failure_dialog_button_key'), + alertTitle: S.of(context).provider_error(state.title), + alertContent: state.error, + buttonText: S.of(context).ok, + buttonAction: () { + _showingFailureDialog = false; + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ); + }, + ); + } + }); + } + + if (state is TradeIsCreatedSuccessfully) { + exchangeViewModel.reset(); + if (Navigator.of(context).canPop() && !_showingSwapDetailsDialog) { + _showingSwapDetailsDialog = true; + Navigator.of(context).pop(); + showModalBottomSheet( + context: context, + isDismissible: true, + isScrollControlled: true, + builder: (BuildContext context) { + _showingSwapDetailsDialog = false; + return getIt.get(); + }, + ); + } + } + }); + + _bestRateReaction = reaction((_) => exchangeViewModel.bestRate, (double rate) { + if (exchangeViewModel.isFixedRateMode) { + exchangeViewModel.changeReceiveAmount(amount: _amountController.text); + } + }); + + _addressController + .addListener(() => exchangeViewModel.receiveAddress = _addressController.text); + + _amountController.addListener(() { + if (_amountController.text != exchangeViewModel.receiveAmount) { + _receiveAmountDebounce.run(() { + exchangeViewModel.loadLimits(); + exchangeViewModel.changeReceiveAmount(amount: _amountController.text); + exchangeViewModel.isReceiveAmountEntered = true; + }); + } + }); + + _amountFiatController.addListener(() { + if (_amountFiatController.text != exchangeViewModel.receiveAmountFiatFormatted) { + _receiveAmountFiatDebounce.run(() { + exchangeViewModel.loadLimits(); + exchangeViewModel.setReceiveAmountFromFiat(fiatAmount: _amountFiatController.text); + }); + } + }); + + _amountFocus.addListener(() { + if (_amountFocus.hasFocus) { + exchangeViewModel.enableFixedRateMode(); + } + }); + + exchangeViewModel.receiveCurrency = walletTypeToCryptoCurrency(paymentFlowResult.walletType!); + await exchangeViewModel.fetchFiatPrice(exchangeViewModel.receiveCurrency); + + exchangeViewModel.receiveAddress = _addressController.text; + exchangeViewModel.depositAddress = exchangeViewModel.wallet.walletAddresses.addressForExchange; + exchangeViewModel.receiveAmount = _amountController.text; + _amountFiatController.text = exchangeViewModel.receiveAmountFiatFormatted; + exchangeViewModel.isReceiveAmountEntered = true; + exchangeViewModel.isFixedRateMode = true; + } +} + +class SwapConfirmationTextfield extends StatelessWidget { + const SwapConfirmationTextfield({ + super.key, + required this.focusNode, + required this.controller, + required this.hintText, + this.walletType, + this.isAddress = false, + this.maxLines = 1, + this.validator, + }); + + final FocusNode focusNode; + final TextEditingController controller; + final String hintText; + final WalletType? walletType; + final bool isAddress; + final int maxLines; + final String? Function(String?)? validator; + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: focusNode.hasFocus + ? BorderSide(color: Theme.of(context).colorScheme.primary) + : BorderSide.none, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hintText, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (isAddress) SizedBox(height: 8), + isAddress + ? AddressFormatter.buildSegmentedAddress( + address: controller.text, + walletType: walletType, + evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w400, + fontSize: 12, + ), + ) + : BaseTextFormField( + isDense: true, + hintText: hintText, + focusNode: focusNode, + hasUnderlineBorder: true, + borderWidth: 0.0, + controller: controller, + maxLines: maxLines, + validator: validator, + ), + ], + ), + ); + } +} + +class SwapConfirmationFooter extends StatelessWidget { + const SwapConfirmationFooter({ + super.key, + required this.exchangeViewModel, + required this.formKey, + required this.authService, + }); + + final ExchangeViewModel exchangeViewModel; + final AuthService authService; + final GlobalKey formKey; + + @override + Widget build(BuildContext context) { + return Container( + height: 150, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Observer( + builder: (_) { + final isLoading = exchangeViewModel.tradeState is TradeIsCreating || + exchangeViewModel.limitsState is LimitsIsLoading; + final isDisabled = exchangeViewModel.selectedProviders.isEmpty || + exchangeViewModel.receiveAmount.isEmpty || + exchangeViewModel.receiveAddress.isEmpty; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + PrimaryButton( + text: S.current.cancel, + onPressed: isLoading + ? null + : () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(null); + } + }, + color: Theme.of(context).colorScheme.surfaceContainer, + textColor: Theme.of(context).colorScheme.onSecondaryContainer, + ), + const SizedBox(height: 12), + LoadingPrimaryButton( + text: S.current.continue_text, + onPressed: exchangeViewModel.isAvailableInSelected + ? () { + FocusScope.of(context).unfocus(); + + if (formKey.currentState != null && formKey.currentState!.validate()) { + final check = exchangeViewModel.shouldDisplayTOTP(); + authService.authenticateAction( + context, + conditionToDetermineIfToUse2FA: check, + onAuthSuccess: (value) { + if (value) { + exchangeViewModel.createTrade(); + } + }, + ); + } + } + : () => PresentProviderPicker(exchangeViewModel: exchangeViewModel) + .presentProviderPicker(context), + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isDisabled: isDisabled, + isLoading: isLoading, + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/src/widgets/bottom_sheet/swap_details_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/swap_details_bottom_sheet.dart new file mode 100644 index 000000000..7894162a1 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/swap_details_bottom_sheet.dart @@ -0,0 +1,441 @@ +import 'package:cake_wallet/exchange/trade.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/show_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cake_wallet/view_model/exchange/exchange_trade_view_model.dart'; +import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart'; +import 'package:cake_wallet/themes/core/material_base_theme.dart'; +import 'package:cake_wallet/core/execution_state.dart'; +import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:flutter/services.dart'; +import 'package:mobx/mobx.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:cake_wallet/reactions/wallet_connect.dart'; + +class SwapDetailsBottomSheet extends StatefulWidget { + SwapDetailsBottomSheet({ + Key? key, + required this.currentTheme, + required this.exchangeTradeViewModel, + }) : super(key: key); + + final MaterialThemeBase currentTheme; + final ExchangeTradeViewModel exchangeTradeViewModel; + + @override + State createState() => _SwapDetailsBottomSheetState(); +} + +class _SwapDetailsBottomSheetState extends State { + bool _effectsInstalled = false; + ReactionDisposer? _exchangeStateReaction; + BuildContext? _loadingBottomSheetContext; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _setEffects()); + } + + @override + void dispose() { + widget.exchangeTradeViewModel.timer?.cancel(); + _exchangeStateReaction?.reaction.dispose(); + super.dispose(); + } + + void _setEffects() { + if (_effectsInstalled) return; + + _exchangeStateReaction = reaction( + (_) => widget.exchangeTradeViewModel.sendViewModel.state, + (ExecutionState state) async { + if (state is! IsExecutingState && + state is! TransactionCommitting && + _loadingBottomSheetContext != null && + _loadingBottomSheetContext!.mounted) { + Navigator.of(_loadingBottomSheetContext!).pop(); + } + + if (state is FailureState) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showPopUp( + context: context, + builder: (BuildContext popupContext) { + return AlertWithOneAction( + key: ValueKey('swap_details_send_failure_dialog_key'), + buttonKey: ValueKey('swap_details_send_failure_dialog_button_key'), + alertTitle: S.of(popupContext).error, + alertContent: state.error, + buttonText: S.of(popupContext).ok, + buttonAction: () => Navigator.of(popupContext).pop(), + ); + }, + ); + }); + } + + if (state is IsExecutingState) { + // Wait a bit to avoid showing the loading dialog if transaction is failed + await Future.delayed(const Duration(milliseconds: 300)); + final currentState = widget.exchangeTradeViewModel.sendViewModel.state; + if (currentState is ExecutedSuccessfullyState || currentState is FailureState) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + showModalBottomSheet( + context: context, + isDismissible: false, + builder: (BuildContext context) { + _loadingBottomSheetContext = context; + return LoadingBottomSheet( + titleText: S.of(context).generating_transaction, + ); + }, + ); + } + }); + } + + if (state is ExecutedSuccessfullyState) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (context.mounted) { + await widget.exchangeTradeViewModel.sendViewModel.commitTransaction(context); + } + }); + } + + if (state is TransactionCommitted) { + WidgetsBinding.instance.addPostFrameCallback( + (_) async { + if (!mounted) return; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext bottomSheetContext) { + return InfoBottomSheet( + currentTheme: widget.currentTheme, + footerType: FooterType.singleActionButton, + titleText: S.of(bottomSheetContext).transaction_sent, + contentImage: 'assets/images/birthday_cake.png', + singleActionButtonText: S.of(bottomSheetContext).close, + singleActionButtonKey: ValueKey('swap_details_sent_dialog_ok_button_key'), + onSingleActionButtonPressed: () { + Navigator.of(bottomSheetContext).pop(); + if (mounted) { + Navigator.of(context).pop(); + } + ; + }, + ); + }, + ); + }, + ); + } + }, + ); + + _effectsInstalled = true; + } + + @override + Widget build(BuildContext context) { + return _SwapDetailsBottomSheetContent( + titleText: 'Confirm Swap', + footerType: FooterType.none, + maxHeight: 900, + currentTheme: widget.currentTheme, + exchangeTradeViewModel: widget.exchangeTradeViewModel, + onExecuteSwap: _executeSwap, + ); + } + + void _executeSwap() async { + if (!widget.exchangeTradeViewModel.isSendable) return; + + try { + await widget.exchangeTradeViewModel.confirmSending(); + } catch (e) { + printV('Error executing swap: $e'); + if (mounted) { + showPopUp( + context: context, + builder: (BuildContext popupContext) { + return AlertWithOneAction( + key: ValueKey('swap_details_execution_error_dialog_key'), + buttonKey: ValueKey('swap_details_execution_error_dialog_button_key'), + alertTitle: S.of(popupContext).error, + alertContent: e.toString(), + buttonText: S.of(popupContext).ok, + buttonAction: () => Navigator.of(popupContext).pop(), + ); + }, + ); + } + } + } +} + +class _SwapDetailsBottomSheetContent extends BaseBottomSheet { + _SwapDetailsBottomSheetContent({ + required String titleText, + required FooterType footerType, + required double maxHeight, + MaterialThemeBase? currentTheme, + required this.exchangeTradeViewModel, + required this.onExecuteSwap, + }) : super( + titleText: titleText, + footerType: footerType, + maxHeight: maxHeight, + currentTheme: currentTheme, + ); + + final ExchangeTradeViewModel exchangeTradeViewModel; + final VoidCallback onExecuteSwap; + + @override + Widget contentWidget(BuildContext context) { + return Column( + children: [ + _SwapDetailsContent( + trade: exchangeTradeViewModel.trade, + exchangeTradeViewModel: exchangeTradeViewModel, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + child: Observer( + builder: (_) { + final sendingState = exchangeTradeViewModel.sendViewModel.state; + final isDisabled = !exchangeTradeViewModel.isSendable || + exchangeTradeViewModel.trade.inputAddress == null || + exchangeTradeViewModel.trade.inputAddress!.isEmpty; + + if (isDisabled || sendingState is IsExecutingState) { + return Container( + width: double.infinity, + height: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surfaceContainer, + ), + child: Center( + child: sendingState is IsExecutingState + ? CircularProgressIndicator() + : Text( + 'Swipe to swap', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + return StandardSlideButton( + tileBackgroundColor: Theme.of(context).colorScheme.surfaceContainer, + knobColor: Theme.of(context).colorScheme.primary, + buttonText: 'Swipe to swap', + onSlideComplete: onExecuteSwap, + currentTheme: currentTheme!, + accessibleNavigationModeButtonText: 'Complete swap', + ); + }, + ), + ), + const SizedBox(height: 32), + ], + ); + } +} + +class _SwapDetailsContent extends StatelessWidget { + const _SwapDetailsContent({required this.trade, required this.exchangeTradeViewModel}); + + final Trade trade; + final ExchangeTradeViewModel exchangeTradeViewModel; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24), + child: Observer( + builder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _SwapDetailsTile( + label: 'You Send', + value: '${trade.amount} ${trade.from?.title ?? ''}', + valueFiatFormatted: exchangeTradeViewModel.sendAmountFiatFormatted, + ), + const SizedBox(height: 8), + _SwapDetailsTile( + label: 'You Get', + value: '${trade.receiveAmount ?? '0'} ${trade.to?.title ?? ''}', + valueFiatFormatted: exchangeTradeViewModel.receiveAmountFiatFormatted, + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'To this Address', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontWeight: FontWeight.w500, fontSize: 16), + ), + const SizedBox(height: 4), + AddressFormatter.buildSegmentedAddress( + address: trade.payoutAddress ?? '', + walletType: trade.to != null ? cryptoCurrencyToWalletType(trade.to!) : null, + evenTextStyle: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontWeight: FontWeight.w600, fontSize: 14), + ), + ], + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Row( + children: [ + CakeImageWidget( + imageUrl: trade.provider.image, + width: 36, + height: 36, + ), + const SizedBox(width: 8), + Text( + trade.provider.title, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + ], + ), + Spacer(), + Expanded( + // flex: 4, + child: GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: trade.id)); + showBar(context, S.of(context).copied_to_clipboard); + }, + child: Row( + children: [ + Text( + 'ID: ', + style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 18), + ), + Expanded( + child: Text( + trade.id, + style: + Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 18), + softWrap: true, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +class _SwapDetailsTile extends StatelessWidget { + const _SwapDetailsTile({ + required this.label, + required this.value, + required this.valueFiatFormatted, + }); + + final String label; + final String value; + final String valueFiatFormatted; + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontWeight: FontWeight.w500, fontSize: 16), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + value, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + if (valueFiatFormatted.isNotEmpty) + Text( + valueFiatFormatted, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/standard_slide_button_widget.dart b/lib/src/widgets/standard_slide_button_widget.dart index 44edabb28..641e05036 100644 --- a/lib/src/widgets/standard_slide_button_widget.dart +++ b/lib/src/widgets/standard_slide_button_widget.dart @@ -11,6 +11,8 @@ class StandardSlideButton extends StatefulWidget { this.height = 48.0, required this.currentTheme, required this.accessibleNavigationModeButtonText, + this.tileBackgroundColor, + this.knobColor, }) : super(key: key); final VoidCallback onSlideComplete; @@ -18,7 +20,9 @@ class StandardSlideButton extends StatefulWidget { final double height; final MaterialThemeBase currentTheme; final String accessibleNavigationModeButtonText; - + final Color? tileBackgroundColor; + final Color? knobColor; + @override StandardSlideButtonState createState() => StandardSlideButtonState(); } @@ -57,7 +61,7 @@ class StandardSlideButtonState extends State { height: widget.height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - color: tileBackgroundColor, + color: widget.tileBackgroundColor ?? tileBackgroundColor, ), child: Stack( alignment: Alignment.centerLeft, @@ -97,7 +101,7 @@ class StandardSlideButtonState extends State { height: widget.height - 8, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - color: Theme.of(context).colorScheme.surface, + color: widget.knobColor ?? Theme.of(context).colorScheme.surface, ), alignment: Alignment.center, child: Icon( diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index ac8bba173..41bb67805 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart'; @@ -15,6 +17,7 @@ import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_item.dart'; +import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; @@ -36,6 +39,7 @@ abstract class ExchangeTradeViewModelBase with Store { required this.tradesStore, required this.sendViewModel, required this.feesViewModel, + required this.fiatConversionStore, }) : trade = tradesStore.trade!, isSendable = _checkIfCanSend(tradesStore, wallet), items = ObservableList() { @@ -69,7 +73,7 @@ abstract class ExchangeTradeViewModelBase with Store { case ExchangeProviderDescription.chainflip: _provider = ChainflipExchangeProvider(tradesStore: trades); break; - case ExchangeProviderDescription.xoSwap: + case ExchangeProviderDescription.xoSwap: _provider = XOSwapExchangeProvider(); } @@ -116,6 +120,49 @@ abstract class ExchangeTradeViewModelBase with Store { Timer? timer; + final FiatConversionStore fiatConversionStore; + + FiatCurrency get fiat => sendViewModel.fiat; + + @computed + bool get isFiatDisabled => feesViewModel.isFiatDisabled; + + @computed + String get receiveAmountFiatFormatted { + var amount = '0.00'; + try { + if (trade.receiveAmount?.isNotEmpty ?? false) { + if (fiatConversionStore.prices[trade.to] == null) return ''; + + amount = calculateFiatAmount( + price: fiatConversionStore.prices[trade.to]!, + cryptoAmount: trade.receiveAmount, + ); + } + } catch (_) { + printV('Error calculating receive amount fiat formatted: $_'); + } + return isFiatDisabled ? '' : '$amount ${fiat.title}'; + } + + @computed + String get sendAmountFiatFormatted { + var amount = '0.00'; + try { + if (trade.amount.isNotEmpty) { + if (fiatConversionStore.prices[trade.from] == null) return ''; + + amount = calculateFiatAmount( + price: fiatConversionStore.prices[trade.from]!, + cryptoAmount: trade.amount, + ); + } + } catch (_) { + printV('Error calculating send amount fiat formatted: $_'); + } + return isFiatDisabled ? '' : '$amount ${fiat.title}'; + } + void setUpOutput() { sendViewModel.clearOutputs(); output = sendViewModel.outputs.first; @@ -168,13 +215,9 @@ abstract class ExchangeTradeViewModelBase with Store { void _updateItems() { final trade = tradesStore.trade!; - final tradeFrom = trade.fromRaw >= 0 - ? trade.from - : trade.userCurrencyFrom; + final tradeFrom = trade.fromRaw >= 0 ? trade.from : trade.userCurrencyFrom; - final tradeTo = trade.toRaw >= 0 - ? trade.to - : trade.userCurrencyTo; + final tradeTo = trade.toRaw >= 0 ? trade.to : trade.userCurrencyTo; final tagFrom = tradeFrom?.tag != null ? '${tradeFrom!.tag}' + ' ' : ''; final tagTo = tradeTo?.tag != null ? '${tradeTo!.tag}' + ' ' : ''; @@ -221,22 +264,20 @@ abstract class ExchangeTradeViewModelBase with Store { final isExtraIdExist = trade.extraId != null && trade.extraId!.isNotEmpty; if (isExtraIdExist) { - - final title = tradeFrom == CryptoCurrency.xrp - ? S.current.destination_tag - : tradeFrom == CryptoCurrency.xlm || tradeFrom == CryptoCurrency.ton - ? S.current.memo - : S.current.extra_id; + ? S.current.destination_tag + : tradeFrom == CryptoCurrency.xlm || tradeFrom == CryptoCurrency.ton + ? S.current.memo + : S.current.extra_id; - items.add( - ExchangeTradeItem( - title: title, - data: trade.extraId ?? '', - isCopied: true, - isReceiveDetail: !isExtraIdExist, - isExternalSendDetail: isExtraIdExist), - ); + items.add( + ExchangeTradeItem( + title: title, + data: trade.extraId ?? '', + isCopied: true, + isReceiveDetail: !isExtraIdExist, + isExternalSendDetail: isExtraIdExist), + ); } items.add( @@ -251,27 +292,21 @@ abstract class ExchangeTradeViewModelBase with Store { } static bool _checkIfCanSend(TradesStore tradesStore, WalletBase wallet) { - final trade = tradesStore.trade!; - final tradeFrom = trade.fromRaw >= 0 - ? trade.from - : trade.userCurrencyFrom; + final tradeFrom = trade.fromRaw >= 0 ? trade.from : trade.userCurrencyFrom; bool _isEthToken() => - wallet.currency == CryptoCurrency.eth && - tradeFrom?.tag == CryptoCurrency.eth.title; + wallet.currency == CryptoCurrency.eth && tradeFrom?.tag == CryptoCurrency.eth.title; bool _isPolygonToken() => wallet.currency == CryptoCurrency.maticpoly && - tradeFrom?.tag == CryptoCurrency.maticpoly.tag; + tradeFrom?.tag == CryptoCurrency.maticpoly.tag; bool _isTronToken() => - wallet.currency == CryptoCurrency.trx && - tradeFrom?.tag == CryptoCurrency.trx.title; + wallet.currency == CryptoCurrency.trx && tradeFrom?.tag == CryptoCurrency.trx.title; bool _isSplToken() => - wallet.currency == CryptoCurrency.sol && - tradeFrom?.tag == CryptoCurrency.sol.title; + wallet.currency == CryptoCurrency.sol && tradeFrom?.tag == CryptoCurrency.sol.title; return tradeFrom == wallet.currency || tradesStore.trade!.provider == ExchangeProviderDescription.xmrto || diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index 0f86828ba..bb820e184 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -1,13 +1,18 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:developer'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/create_trade_result.dart'; +import 'package:cake_wallet/core/fiat_conversion_service.dart'; import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; +import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; +import 'package:cake_wallet/entities/fiat_api_mode.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/entities/wallet_contact.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; @@ -30,6 +35,7 @@ import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/exchange/trade_request.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/store/app_store.dart'; +import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/store/templates/exchange_template_store.dart'; @@ -76,6 +82,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with this.unspentCoinsListViewModel, this.feesViewModel, this.walletInfoSource, + this.fiatConversionStore, ) : _cryptoNumberFormat = NumberFormat(), isSendAllEnabled = false, isFixedRateMode = false, @@ -129,8 +136,8 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with autorun((_) { if (selectedProviders.any((provider) => provider is TrocadorExchangeProvider)) { final trocadorProvider = - selectedProviders.firstWhere((provider) => provider is TrocadorExchangeProvider) - as TrocadorExchangeProvider; + selectedProviders.firstWhere((provider) => provider is TrocadorExchangeProvider) + as TrocadorExchangeProvider; updateAllTrocadorProviderStates(trocadorProvider); } @@ -360,6 +367,44 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with late Timer bestRateSync; + final FiatConversionStore fiatConversionStore; + + FiatCurrency get fiat => _settingsStore.fiatCurrency; + + @computed + bool get isFiatDisabled => feesViewModel.isFiatDisabled; + + @action + Future fetchFiatPrice(CryptoCurrency currency) async { + if (fiatConversionStore.prices[currency] != null) { + return; + } + + fiatConversionStore.prices[currency] = await FiatConversionService.fetchPrice( + crypto: currency, + fiat: fiat, + torOnly: _settingsStore.fiatApiMode == FiatApiMode.torOnly, + ); + } + + @computed + String get receiveAmountFiatFormatted { + var amount = '0.00'; + try { + if (receiveAmount.isNotEmpty) { + if (fiatConversionStore.prices[receiveCurrency] == null) return ''; + + amount = calculateFiatAmount( + price: fiatConversionStore.prices[receiveCurrency]!, + cryptoAmount: receiveAmount, + ); + } + } catch (_) { + log('Error calculating receive amount fiat formatted: $_'); + } + return isFiatDisabled ? '' : '$amount'; + } + @action void changeDepositCurrency({required CryptoCurrency currency}) { depositCurrency = currency; @@ -393,6 +438,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with await calculateBestRate(); } + _cryptoNumberFormat.maximumFractionDigits = depositMaxDigits; depositAmount = _cryptoNumberFormat @@ -401,6 +447,16 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with .replaceAll(RegExp('\\,'), ''); } + @action + void setReceiveAmountFromFiat({required String fiatAmount}) { + final crypto = double.parse(fiatAmount.replaceAll(',', '.')) / + fiatConversionStore.prices[receiveCurrency]!; + final receiveAmountTmp = _cryptoNumberFormat.format(crypto); + if (receiveAmount != receiveAmountTmp) { + changeReceiveAmount(amount: receiveAmountTmp); + } + } + @action Future changeDepositAmount({required String amount}) async { depositAmount = amount; @@ -444,8 +500,8 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with bestRate = 0.0; return; } - final amount = double.tryParse(isFixedRateMode ? receiveAmount : depositAmount) - ?? initialAmountByAssets(isFixedRateMode ? receiveCurrency : depositCurrency); + final amount = double.tryParse(isFixedRateMode ? receiveAmount : depositAmount) ?? + initialAmountByAssets(isFixedRateMode ? receiveCurrency : depositCurrency); final validProvidersForAmount = _tradeAvailableProviders.where((provider) { final limits = _providerLimits[provider]; @@ -515,15 +571,15 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with .map((provider) async { final limits = await provider .fetchLimits( - from: from, - to: to, - isFixedRateMode: isFixedRateMode, - ) + from: from, + to: to, + isFixedRateMode: isFixedRateMode, + ) .onError((error, stackTrace) => Limits(max: 0.0, min: double.maxFinite)) .timeout( - Duration(seconds: 7), - onTimeout: () => Limits(max: 0.0, min: double.maxFinite), - ); + Duration(seconds: 7), + onTimeout: () => Limits(max: 0.0, min: double.maxFinite), + ); return MapEntry(provider, limits); }).toList(); @@ -563,8 +619,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with Future createTrade() async { if (depositCurrency == receiveCurrency) { tradeState = TradeIsCreatedFailure( - title: S.current.trade_not_created, - error: 'Can\'t exchange the same currency'); + title: S.current.trade_not_created, error: 'Can\'t exchange the same currency'); return; } if (isSendAllEnabled) { @@ -881,8 +936,8 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with void _setAvailableProviders() { _tradeAvailableProviders.clear(); - _tradeAvailableProviders.addAll( - selectedProviders.where((provider) => providerList.contains(provider))); + _tradeAvailableProviders + .addAll(selectedProviders.where((provider) => providerList.contains(provider))); } void _setProviders() { @@ -984,7 +1039,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with "X-API-Key": secrets.moralisApiKey, }, ); - final decodedResponse = jsonDecode(response.body)[0] as Map; @@ -1018,7 +1072,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with Future> _loadAllUniqueEvmTokens() async { final evmWallets = walletInfoSource.values.where( - (w) => w.type == WalletType.ethereum || w.type == WalletType.polygon, + (w) => w.type == WalletType.ethereum || w.type == WalletType.polygon, ); final seen = {}; @@ -1039,7 +1093,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with return unique; } - @action Future _injectUserEthTokensIntoCurrencyLists() async { final userTokens = await _loadAllUniqueEvmTokens(); @@ -1134,8 +1187,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } Future> _loadAllUniqueTronTokens() async { - final tronWallets = - walletInfoSource.values.where((w) => w.type == WalletType.tron); + final tronWallets = walletInfoSource.values.where((w) => w.type == WalletType.tron); final seen = {}; final unique = []; @@ -1186,11 +1238,8 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with CryptoCurrency.ada => 1000, CryptoCurrency.dash => 10, CryptoCurrency.rune => 10, - _ => 1 }; return amount.toDouble(); } - - } diff --git a/lib/view_model/payment/payment_view_model.dart b/lib/view_model/payment/payment_view_model.dart index 21f505f8a..012c7f045 100644 --- a/lib/view_model/payment/payment_view_model.dart +++ b/lib/view_model/payment/payment_view_model.dart @@ -44,7 +44,7 @@ abstract class PaymentViewModelBase with Store { return PaymentFlowResult.incompatible('Unable to detect address type'); } - detectedWalletType = detectionResult.detectedWalletType!; + detectedWalletType = detectionResult.detectedWalletType; // Check if current wallet is compatible final currentWallet = appStore.wallet; @@ -56,11 +56,11 @@ abstract class PaymentViewModelBase with Store { switch (compatibleWallets.length) { case 0: - return PaymentFlowResult.noWallets(detectedWalletType!); + return PaymentFlowResult.noWallets(detectedWalletType!, detectionResult); case 1: - return PaymentFlowResult.singleWallet(compatibleWallets.first); + return PaymentFlowResult.singleWallet(compatibleWallets.first, detectionResult); default: - return PaymentFlowResult.multipleWallets(compatibleWallets); + return PaymentFlowResult.multipleWallets(compatibleWallets, detectionResult); } } catch (e) { printV('PaymentViewModel error: $e'); @@ -81,6 +81,7 @@ class PaymentFlowResult { final WalletInfo? wallet; final List wallets; final WalletType? walletType; + final AddressDetectionResult? addressDetectionResult; PaymentFlowResult._({ required this.type, @@ -88,22 +89,38 @@ class PaymentFlowResult { this.wallet, this.wallets = const [], this.walletType, + this.addressDetectionResult, }); /// Current wallet is compatible - factory PaymentFlowResult.currentWalletCompatible() => PaymentFlowResult._(type: PaymentFlowType.currentWalletCompatible); + factory PaymentFlowResult.currentWalletCompatible() => + PaymentFlowResult._(type: PaymentFlowType.currentWalletCompatible); /// Single compatible wallet available - factory PaymentFlowResult.singleWallet(WalletInfo wallet) => - PaymentFlowResult._(type: PaymentFlowType.singleWallet, wallet: wallet); + factory PaymentFlowResult.singleWallet( + WalletInfo wallet, AddressDetectionResult addressDetectionResult) => + PaymentFlowResult._( + type: PaymentFlowType.singleWallet, + wallet: wallet, + walletType: wallet.type, + addressDetectionResult: addressDetectionResult); /// Multiple compatible wallets available - factory PaymentFlowResult.multipleWallets(List wallets) => - PaymentFlowResult._(type: PaymentFlowType.multipleWallets, wallets: wallets); + factory PaymentFlowResult.multipleWallets( + List wallets, AddressDetectionResult addressDetectionResult) => + PaymentFlowResult._( + type: PaymentFlowType.multipleWallets, + wallets: wallets, + walletType: wallets.first.type, + addressDetectionResult: addressDetectionResult); /// No compatible wallets available - factory PaymentFlowResult.noWallets(WalletType walletType) => - PaymentFlowResult._(type: PaymentFlowType.noWallets, walletType: walletType); + factory PaymentFlowResult.noWallets( + WalletType walletType, AddressDetectionResult addressDetectionResult) => + PaymentFlowResult._( + type: PaymentFlowType.noWallets, + walletType: walletType, + addressDetectionResult: addressDetectionResult); /// Error occurred factory PaymentFlowResult.error(String message) => diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 9350038de..9c80cadd8 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -11,7 +11,10 @@ import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed import 'package:cake_wallet/tron/tron.dart'; import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cake_wallet/zano/zano.dart'; +import 'package:cw_core/balance.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/transaction_history.dart'; +import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -220,7 +223,8 @@ abstract class OutputBase with Store { WalletType get walletType => _wallet.type; final CryptoCurrency Function() cryptoCurrencyHandler; - final WalletBase _wallet; + @observable + WalletBase, TransactionInfo> _wallet; final SettingsStore _settingsStore; final FiatConversionStore _fiatConversationStore; final NumberFormat _cryptoNumberFormat; @@ -230,6 +234,12 @@ abstract class OutputBase with Store { sendAll = true; } + @action + void updateWallet(WalletBase, TransactionInfo> newWallet) { + _wallet = newWallet; + _setCryptoNumMaximumFractionDigits(); + } + @action void reset() { sendAll = false; diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index a43fbbf58..841673d16 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -72,6 +72,16 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor wallet.type == WalletType.solana || wallet.type == WalletType.tron || wallet.type == WalletType.zano; + + for (final output in outputs) { + output.updateWallet(wallet); + } + + // Update unspent coins list view model with the new wallet reference + unspentCoinsListViewModel.updateWallet(wallet); + + // Update sending balance to reflect the new wallet's balance + updateSendingBalance(); } UnspentCoinsListViewModel unspentCoinsListViewModel; diff --git a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart b/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart index 5799cb58d..a5103fa33 100644 --- a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart +++ b/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart @@ -4,6 +4,9 @@ import 'package:cake_wallet/utils/exception_handler.dart'; import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart'; import 'package:cake_wallet/wownero/wownero.dart'; +import 'package:cw_core/balance.dart'; +import 'package:cw_core/transaction_history.dart'; +import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/unspent_transaction_output.dart'; @@ -28,7 +31,8 @@ abstract class UnspentCoinsListViewModelBase with Store { items = ObservableList(), _originalState = {}; - final WalletBase wallet; + @observable + WalletBase, TransactionInfo> wallet; final Box _unspentCoinsInfo; final UnspentCoinType coinTypeToSpendFrom; @@ -228,6 +232,11 @@ abstract class UnspentCoinsListViewModelBase with Store { @action void setIsDisposing(bool value) => isDisposing = value; + @action + void updateWallet(WalletBase newWallet) { + wallet = newWallet; + } + @action Future dispose() async { await _updateUnspents();