feat: Pay Anything from Any Wallet (#2450)

* feat: Pay Anything from Any Wallet

* feat: Trigger payment flow when address is pasted

* chore: Remove unneeded changes

* feat Expand address checks and fix scanning bch qrs

* fix: Add support for dogecoin

* feat: More modifications for Pay from Any Wallet

This change:
- Adds the pay from any wallet flow to externally signed QR codes
- Adds fixes for review comments
- Fixes failing tests

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
David Adegoke
2025-08-21 18:37:50 +03:00
committed by GitHub
co-authored by Omar Hatem
parent 16f058e9e2
commit 13a74133f5
39 changed files with 1228 additions and 27 deletions
+8
View File
@@ -31,7 +31,9 @@ import 'package:cake_wallet/utils/payment_request.dart';
import 'package:cake_wallet/utils/request_review_handler.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/payment/payment_view_model.dart';
import 'package:cake_wallet/view_model/send/output.dart';
import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cake_wallet/view_model/send/send_view_model.dart';
@@ -48,10 +50,14 @@ class SendPage extends BasePage {
SendPage({
required this.sendViewModel,
required this.authService,
required this.paymentViewModel,
required this.walletSwitcherViewModel,
this.initialPaymentRequest,
}) : _formKey = GlobalKey<FormState>();
final SendViewModel sendViewModel;
final PaymentViewModel paymentViewModel;
final WalletSwitcherViewModel walletSwitcherViewModel;
final AuthService authService;
final GlobalKey<FormState> _formKey;
final controller = PageController(initialPage: 0);
@@ -179,6 +185,8 @@ class SendPage extends BasePage {
key: output.key,
output: output,
sendViewModel: sendViewModel,
paymentViewModel: paymentViewModel,
walletSwitcherViewModel: walletSwitcherViewModel,
initialPaymentRequest: initialPaymentRequest,
cryptoAmountFocus: cryptoAmountFocus,
fiatAmountFocus: fiatAmountFocus,
+172 -26
View File
@@ -1,6 +1,8 @@
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/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/picker.dart';
import 'package:cake_wallet/src/widgets/standard_checkbox.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
@@ -8,12 +10,16 @@ import 'package:cake_wallet/src/widgets/alert_with_one_action.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:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/view_model/send/output.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@@ -29,6 +35,8 @@ class SendCard extends StatefulWidget {
Key? key,
required this.output,
required this.sendViewModel,
required this.paymentViewModel,
required this.walletSwitcherViewModel,
required this.currentTheme,
this.initialPaymentRequest,
this.cryptoAmountFocus,
@@ -37,6 +45,8 @@ class SendCard extends StatefulWidget {
final Output output;
final SendViewModel sendViewModel;
final PaymentViewModel paymentViewModel;
final WalletSwitcherViewModel walletSwitcherViewModel;
final PaymentRequest? initialPaymentRequest;
final FocusNode? cryptoAmountFocus;
final FocusNode? fiatAmountFocus;
@@ -44,14 +54,16 @@ class SendCard extends StatefulWidget {
@override
SendCardState createState() => SendCardState(
output: output,
sendViewModel: sendViewModel,
initialPaymentRequest: initialPaymentRequest,
currentTheme: currentTheme
// cryptoAmountFocus: cryptoAmountFocus ?? FocusNode(),
// fiatAmountFocus: fiatAmountFocus ?? FocusNode(),
// cryptoAmountFocus: FocusNode(),
// fiatAmountFocus: FocusNode(),
output: output,
sendViewModel: sendViewModel,
paymentViewModel: paymentViewModel,
walletSwitcherViewModel: walletSwitcherViewModel,
initialPaymentRequest: initialPaymentRequest,
currentTheme: currentTheme,
// cryptoAmountFocus: cryptoAmountFocus ?? FocusNode(),
// fiatAmountFocus: fiatAmountFocus ?? FocusNode(),
// cryptoAmountFocus: FocusNode(),
// fiatAmountFocus: FocusNode(),
);
}
@@ -59,6 +71,8 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
SendCardState({
required this.output,
required this.sendViewModel,
required this.paymentViewModel,
required this.walletSwitcherViewModel,
this.initialPaymentRequest,
required this.currentTheme,
}) : addressController = TextEditingController(),
@@ -74,6 +88,8 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
final MaterialThemeBase currentTheme;
final Output output;
final SendViewModel sendViewModel;
final PaymentViewModel paymentViewModel;
final WalletSwitcherViewModel walletSwitcherViewModel;
final PaymentRequest? initialPaymentRequest;
final TextEditingController addressController;
@@ -98,23 +114,141 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
WidgetsBinding.instance.addPostFrameCallback(
(timeStamp) {
if (mounted) {
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!);
}
},
);
}
}
@override
void dispose() {
addressController.dispose();
cryptoAmountController.dispose();
fiatAmountController.dispose();
noteController.dispose();
extractedAddressController.dispose();
addressFocusNode.dispose();
super.dispose();
}
Future<void> _handlePaymentFlow(String uri, PaymentRequest paymentRequest) async {
try {
final result = await paymentViewModel.processAddress(uri);
switch (result.type) {
case PaymentFlowType.singleWallet:
case PaymentFlowType.multipleWallets:
case PaymentFlowType.noWallets:
await _showPaymentConfirmation(
paymentViewModel,
walletSwitcherViewModel,
paymentRequest,
result,
);
break;
case PaymentFlowType.currentWalletCompatible:
case PaymentFlowType.error:
case PaymentFlowType.incompatible:
_applyPaymentRequest(paymentRequest);
break;
}
} catch (e) {
printV('Payment flow error: $e');
_applyPaymentRequest(paymentRequest);
}
}
Future<void> _showPaymentConfirmation(
PaymentViewModel paymentViewModel,
WalletSwitcherViewModel walletSwitcherViewModel,
PaymentRequest paymentRequest,
PaymentFlowResult result,
) async {
await showModalBottomSheet<void>(
context: context,
isDismissible: true,
isScrollControlled: true,
builder: (BuildContext context) {
return PaymentConfirmationBottomSheet(
paymentFlowResult: result,
paymentViewModel: paymentViewModel,
walletSwitcherViewModel: walletSwitcherViewModel,
currentTheme: currentTheme,
paymentRequest: paymentRequest,
onSelectWallet: () => _handleSelectWallet(
paymentViewModel,
walletSwitcherViewModel,
paymentRequest,
),
onChangeWallet: () => _handleChangeWallet(
paymentViewModel,
walletSwitcherViewModel,
paymentRequest,
result,
),
onSwap: () => Navigator.of(context).pushNamed(Routes.exchange, arguments: paymentRequest),
);
},
);
}
Future<void> _handleSelectWallet(
PaymentViewModel paymentViewModel,
WalletSwitcherViewModel walletSwitcherViewModel,
PaymentRequest paymentRequest,
) async {
Navigator.of(context).pop();
await showModalBottomSheet<WalletInfo>(
context: context,
isDismissible: true,
isScrollControlled: true,
builder: (BuildContext dialogContext) {
return WalletSwitcherBottomSheet(
viewModel: walletSwitcherViewModel,
currentTheme: currentTheme,
filterWalletType: paymentViewModel.detectedWalletType,
);
},
);
final success = await walletSwitcherViewModel.switchToSelectedWallet();
if (success) {
_applyPaymentRequest(paymentRequest);
}
}
Future<void> _handleChangeWallet(
PaymentViewModel paymentViewModel,
WalletSwitcherViewModel walletSwitcherViewModel,
PaymentRequest paymentRequest,
PaymentFlowResult result,
) async {
Navigator.of(context).pop();
if (result.wallet != null) {
walletSwitcherViewModel.selectWallet(result.wallet!);
final success = await walletSwitcherViewModel.switchToSelectedWallet();
if (success) {
_applyPaymentRequest(paymentRequest);
}
}
}
/// Apply payment request to current form
void _applyPaymentRequest(PaymentRequest paymentRequest) {
if (sendViewModel.usePayjoin) {
sendViewModel.payjoinUri = paymentRequest.pjUri;
}
addressController.text = paymentRequest.address;
cryptoAmountController.text = paymentRequest.amount;
noteController.text = paymentRequest.note;
}
@override
Widget build(BuildContext context) {
super.build(context);
@@ -185,13 +319,11 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
if (OpenCryptoPayService.isOpenCryptoPayQR(uri.toString())) {
sendViewModel.createOpenCryptoPayTransaction(uri.toString());
} else {
final paymentRequest = PaymentRequest.fromUri(uri);
if (sendViewModel.usePayjoin) {
sendViewModel.payjoinUri = paymentRequest.pjUri;
}
addressController.text = paymentRequest.address;
cryptoAmountController.text = paymentRequest.amount;
noteController.text = paymentRequest.note;
// Process the payment through the new flow
await _handlePaymentFlow(
uri.toString(),
PaymentRequest.fromUri(uri),
);
}
},
options: [
@@ -211,6 +343,20 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
onPushPasteButton: (context) async {
output.resetParsedAddress();
await output.fetchParsedAddress(context);
final address =
output.isParsedAddress ? output.extractedAddress : output.address;
await _handlePaymentFlow(
address,
PaymentRequest(
address,
cryptoAmountController.text,
noteController.text,
"",
null,
),
);
},
onPushAddressBookButton: (context) async {
output.resetParsedAddress();
@@ -0,0 +1,161 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/utils/payment_request.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/wallet_switcher_view_model.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class PaymentConfirmationBottomSheet extends BaseBottomSheet {
PaymentConfirmationBottomSheet({
Key? key,
required this.paymentFlowResult,
required this.paymentViewModel,
required this.walletSwitcherViewModel,
required this.currentTheme,
required this.paymentRequest,
required this.onSelectWallet,
required this.onChangeWallet,
required this.onSwap,
}) : super(
titleText: '',
footerType: FooterType.none,
maxHeight: 900,
currentTheme: currentTheme,
);
final PaymentFlowResult paymentFlowResult;
final PaymentViewModel paymentViewModel;
final WalletSwitcherViewModel walletSwitcherViewModel;
final MaterialThemeBase currentTheme;
final PaymentRequest paymentRequest;
final VoidCallback onSelectWallet;
final VoidCallback onChangeWallet;
final VoidCallback onSwap;
@override
Widget contentWidget(BuildContext context) {
return _PaymentConfirmationContent(
paymentFlowResult: paymentFlowResult,
paymentViewModel: paymentViewModel,
walletSwitcherViewModel: walletSwitcherViewModel,
paymentRequest: paymentRequest,
onSelectWallet: onSelectWallet,
onChangeWallet: onChangeWallet,
onSwap: onSwap,
);
}
}
class _PaymentConfirmationContent extends StatelessWidget {
const _PaymentConfirmationContent({
required this.paymentFlowResult,
required this.paymentViewModel,
required this.walletSwitcherViewModel,
required this.paymentRequest,
required this.onSelectWallet,
required this.onChangeWallet,
required this.onSwap,
});
final PaymentFlowResult paymentFlowResult;
final PaymentViewModel paymentViewModel;
final WalletSwitcherViewModel walletSwitcherViewModel;
final PaymentRequest paymentRequest;
final VoidCallback onSelectWallet;
final VoidCallback onChangeWallet;
final VoidCallback onSwap;
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
final currencyName = walletTypeToString(paymentViewModel.detectedWalletType!);
final currentWalletName = walletTypeToString(paymentViewModel.currentWalletType);
final hasMultipleWallets = paymentFlowResult.type == PaymentFlowType.multipleWallets;
final noAvailableWallets = paymentFlowResult.type == PaymentFlowType.noWallets;
final hasAtLeastOneWallet =
paymentFlowResult.type == PaymentFlowType.singleWallet || hasMultipleWallets;
return Container(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
walletTypeToCryptoCurrency(paymentViewModel.detectedWalletType!).iconPath!,
width: 118,
height: 118,
),
const SizedBox(height: 20),
Text(
'$currencyName ${S.current.address_detected.toLowerCase()}',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontSize: 20,
fontWeight: FontWeight.w900,
color: Theme.of(context).colorScheme.onSurface,
letterSpacing: 0.0,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Text(
'''Looks like you scanned a $currencyName address.\n\n'''
'''Would you like to ${!noAvailableWallets ? 'switch to a $currencyName wallet or' : ''} swap $currentWalletName for $currencyName for this payment?''',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: 0.0,
),
),
const SizedBox(height: 72),
if (hasAtLeastOneWallet) ...[
PrimaryButton(
onPressed: onSwap,
text: '${S.current.swap} $currentWalletName',
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(height: 10),
PrimaryButton(
onPressed: hasMultipleWallets ? onSelectWallet : onChangeWallet,
text: S.current.switch_wallet,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
const SizedBox(height: 10),
] else ...[
PrimaryButton(
onPressed: () => Navigator.of(context).pop(),
text: S.current.cancel,
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(height: 10),
PrimaryButton(
onPressed: onSwap,
text: '${S.current.swap} $currentWalletName',
color: hasAtLeastOneWallet
? Theme.of(context).colorScheme.surfaceContainer
: Theme.of(context).colorScheme.primary,
textColor: hasAtLeastOneWallet
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onPrimary,
),
const SizedBox(height: 32),
],
],
),
);
},
);
}
}
@@ -0,0 +1,130 @@
import 'package:cake_wallet/generated/i18n.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/standard_list.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
class WalletSwitcherBottomSheet extends BaseBottomSheet {
WalletSwitcherBottomSheet({
Key? key,
required this.viewModel,
required this.currentTheme,
this.filterWalletType,
this.subtitle,
this.onWalletSelected,
}) : super(
titleText: S.current.select_a_wallet,
footerType: FooterType.none,
maxHeight: 900,
currentTheme: currentTheme,
);
final WalletSwitcherViewModel viewModel;
final MaterialThemeBase currentTheme;
final WalletType? filterWalletType;
final String? subtitle;
final Function(WalletInfo)? onWalletSelected;
@override
Widget contentWidget(BuildContext context) {
return _WalletSwitcherContent(
viewModel: viewModel,
filterWalletType: filterWalletType,
subtitle: subtitle,
onWalletSelected: onWalletSelected,
);
}
}
class _WalletSwitcherContent extends StatelessWidget {
const _WalletSwitcherContent({
required this.viewModel,
this.filterWalletType,
this.subtitle,
this.onWalletSelected,
});
final WalletSwitcherViewModel viewModel;
final WalletType? filterWalletType;
final String? subtitle;
final Function(WalletInfo)? onWalletSelected;
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
final wallets = viewModel.getWallets(filterWalletType);
if (viewModel.isProcessing) {
return Container(
height: 200,
child: Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
return Container(
height: 400,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: StandardList(
itemCount: wallets.length,
itemBuilder: (context, index) {
final wallet = wallets[index];
return InkWell(
onTap: () {
viewModel.selectWallet(wallet);
Navigator.of(context).pop();
},
borderRadius: BorderRadius.circular(12),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
),
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Image.asset(
walletTypeToCryptoCurrency(wallet.type).iconPath!,
width: 32,
height: 32,
),
const SizedBox(width: 16),
Text(
wallet.name,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
letterSpacing: 0.0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
),
),
],
),
);
},
);
}
}