feat: Swap flow for Pay Anything. (#2489)

* feat: Swap flow for Pay Anything. [WIP]

* feat: Pay Anything Swap Flow [WIP]

* feat: Pay anything swap flow [WIP]
- Add fiat values for send and receive amounts to swap details confirmation bottomsheet

* feat: Pay anything swap flow [WIP]
- Switch exchangeviewmodel back to factory
- Expand address box to fill width

* Handle second swipe to swap cta

* Add fiat for receive amount

* Scope Observers for bottomsheets and add validator

* fix: Repeated instances issue for exchangeVM

* fix: Estimated fee and balance not correctly updating after wallet switching occurs

* fix: Available balance not updating properly after wallet switching

* Fix: Give wallet some time to open before populating send info

---------

Co-authored-by: OmarHatem <omarh.ismail1@gmail.com>
This commit is contained in:
David Adegoke
2025-09-06 19:16:56 +03:00
committed by GitHub
co-authored by OmarHatem
parent 31b9bb7f53
commit d1b62f2664
13 changed files with 1281 additions and 97 deletions
+21
View File
@@ -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<void> setup({
getIt.get<UnspentCoinsListViewModel>(),
getIt.get<FeesViewModel>(),
_walletInfoSource,
getIt.get<FiatConversionStore>(),
),
);
@@ -1113,6 +1117,7 @@ Future<void> setup({
tradesStore: getIt.get<TradesStore>(),
sendViewModel: getIt.get<SendViewModel>(),
feesViewModel: getIt.get<FeesViewModel>(),
fiatConversionStore: getIt.get<FiatConversionStore>(),
),
);
@@ -1133,6 +1138,22 @@ Future<void> setup({
getIt.registerFactory(() => ExchangeTemplatePage(getIt.get<ExchangeViewModel>()));
getIt.registerFactoryParam<SwapConfirmationBottomSheet, PaymentFlowResult, void>(
(paymentFlowResult, _) => SwapConfirmationBottomSheet(
paymentFlowResult: paymentFlowResult,
currentTheme: getIt.get<AppStore>().themeStore.currentTheme,
exchangeViewModel: getIt.get<ExchangeViewModel>(),
authService: getIt.get<AuthService>(),
),
);
getIt.registerFactory<SwapDetailsBottomSheet>(
() => SwapDetailsBottomSheet(
currentTheme: getIt.get<AppStore>().themeStore.currentTheme,
exchangeTradeViewModel: getIt.get<ExchangeTradeViewModel>(),
),
);
getIt.registerFactory(() => PaymentViewModel(
appStore: getIt.get<AppStore>(),
walletInfoSource: _walletInfoSource,
+17 -8
View File
@@ -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;
+42 -17
View File
@@ -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<SendCard> with AutomaticKeepAliveClientMixin<S
final FocusNode addressFocusNode;
bool _effectsInstalled = false;
BuildContext? loadingBottomSheetContext;
@override
void initState() {
@@ -114,18 +121,9 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
WidgetsBinding.instance.addPostFrameCallback(
(timeStamp) {
if (mounted) {
// TODO: revert this as well
showPopUp<void>(
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<SendCard> with AutomaticKeepAliveClientMixin<S
Future<void> _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<SendCard> with AutomaticKeepAliveClientMixin<S
paymentRequest,
result,
),
onSwap: () => Navigator.of(context).pushNamed(Routes.exchange, arguments: paymentRequest),
onSwap: () => _handleSwapFlow(paymentViewModel, result),
);
},
);
@@ -246,6 +241,25 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
walletSwitcherViewModel.selectWallet(result.wallet!);
final success = await walletSwitcherViewModel.switchToSelectedWallet();
if (success) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
showModalBottomSheet<void>(
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<SendCard> with AutomaticKeepAliveClientMixin<S
noteController.text = paymentRequest.note;
}
Future<void> _handleSwapFlow(PaymentViewModel paymentViewModel, PaymentFlowResult result) async {
Navigator.of(context).pop();
final bottomSheet = getIt.get<SwapConfirmationBottomSheet>(param1: result);
await showModalBottomSheet<Trade?>(
context: context,
isDismissible: true,
isScrollControlled: true,
builder: (BuildContext context) => bottomSheet,
);
}
@override
Widget build(BuildContext context) {
super.build(context);
+1 -1
View File
@@ -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,
@@ -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<SwapConfirmationContent> {
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<FormState>();
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: <Widget>[
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<void>(
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<void>(
context: context,
isDismissible: true,
isScrollControlled: true,
builder: (BuildContext context) {
_showingSwapDetailsDialog = false;
return getIt.get<SwapDetailsBottomSheet>();
},
);
}
}
});
_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<FormState> 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,
),
],
);
},
),
);
}
}
@@ -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<SwapDetailsBottomSheet> createState() => _SwapDetailsBottomSheetState();
}
class _SwapDetailsBottomSheetState extends State<SwapDetailsBottomSheet> {
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<void>(
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<void>(
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<void>(
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<void>(
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<void>(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,
),
),
],
),
],
),
);
}
}
@@ -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<StandardSlideButton> {
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<StandardSlideButton> {
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(
@@ -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<ExchangeTradeItem>() {
@@ -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 ||
@@ -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<void> 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<void> 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<void> 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<String, dynamic>;
@@ -1018,7 +1072,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
Future<List<Erc20Token>> _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 = <String>{};
@@ -1039,7 +1093,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
return unique;
}
@action
Future<void> _injectUserEthTokensIntoCurrencyLists() async {
final userTokens = await _loadAllUniqueEvmTokens();
@@ -1134,8 +1187,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
}
Future<List<TronToken>> _loadAllUniqueTronTokens() async {
final tronWallets =
walletInfoSource.values.where((w) => w.type == WalletType.tron);
final tronWallets = walletInfoSource.values.where((w) => w.type == WalletType.tron);
final seen = <String>{};
final unique = <TronToken>[];
@@ -1186,11 +1238,8 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
CryptoCurrency.ada => 1000,
CryptoCurrency.dash => 10,
CryptoCurrency.rune => 10,
_ => 1
};
return amount.toDouble();
}
}
+28 -11
View File
@@ -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<WalletInfo> 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<WalletInfo> wallets) =>
PaymentFlowResult._(type: PaymentFlowType.multipleWallets, wallets: wallets);
factory PaymentFlowResult.multipleWallets(
List<WalletInfo> 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) =>
+11 -1
View File
@@ -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<Balance, TransactionHistoryBase<TransactionInfo>, 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<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo> newWallet) {
_wallet = newWallet;
_setCryptoNumMaximumFractionDigits();
}
@action
void reset() {
sendAll = false;
+10
View File
@@ -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;
@@ -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<UnspentCoinsItem>(),
_originalState = {};
final WalletBase wallet;
@observable
WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo> wallet;
final Box<UnspentCoinsInfo> _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<void> dispose() async {
await _updateUnspents();