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
+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(