mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
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:
co-authored by
Omar Hatem
parent
16f058e9e2
commit
13a74133f5
@@ -0,0 +1,232 @@
|
||||
import 'package:cake_wallet/utils/payment_request.dart';
|
||||
import 'package:cw_core/crypto_currency.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
|
||||
class AddressDetectionResult {
|
||||
AddressDetectionResult({
|
||||
required this.address,
|
||||
this.detectedCurrency,
|
||||
this.detectedWalletType,
|
||||
this.amount,
|
||||
this.note,
|
||||
this.scheme,
|
||||
this.pjUri,
|
||||
this.callbackUrl,
|
||||
this.callbackMessage,
|
||||
required this.isValid,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final String address;
|
||||
final CryptoCurrency? detectedCurrency;
|
||||
final WalletType? detectedWalletType;
|
||||
final String? amount;
|
||||
final String? note;
|
||||
final String? scheme;
|
||||
final String? pjUri;
|
||||
final String? callbackUrl;
|
||||
final String? callbackMessage;
|
||||
final bool isValid;
|
||||
final String? errorMessage;
|
||||
}
|
||||
|
||||
/// Universal address detector that can identify cryptocurrency addresses from various formats
|
||||
class UniversalAddressDetector {
|
||||
/// Detects cryptocurrency address from various input formats
|
||||
/// Supports: raw addresses, URIs, and QR codes
|
||||
static AddressDetectionResult detectAddress(String input) {
|
||||
if (input.trim().isEmpty) {
|
||||
return AddressDetectionResult(
|
||||
address: '',
|
||||
detectedCurrency: null,
|
||||
isValid: false,
|
||||
errorMessage: 'Empty input provided',
|
||||
);
|
||||
}
|
||||
|
||||
// Try to parse as URI first
|
||||
final uriResult = _detectFromUri(input);
|
||||
if (uriResult.isValid) return uriResult;
|
||||
|
||||
// Try to detect from raw address patterns
|
||||
final rawAddressResult = _detectFromRawAddress(input);
|
||||
if (rawAddressResult.isValid) return rawAddressResult;
|
||||
|
||||
return AddressDetectionResult(
|
||||
address: input,
|
||||
detectedCurrency: null,
|
||||
isValid: false,
|
||||
errorMessage: 'Unable to detect valid cryptocurrency address',
|
||||
);
|
||||
}
|
||||
|
||||
/// Detects address from URI format (e.g., bitcoin:address?amount=0.001)
|
||||
static AddressDetectionResult _detectFromUri(String input) {
|
||||
try {
|
||||
final uri = Uri.parse(input);
|
||||
|
||||
final paymentRequest = PaymentRequest.fromUri(uri);
|
||||
|
||||
// Determine currency from scheme
|
||||
final currency = CryptoCurrency.fromString(uri.scheme.toLowerCase());
|
||||
|
||||
return AddressDetectionResult(
|
||||
address: paymentRequest.address,
|
||||
detectedCurrency: currency,
|
||||
detectedWalletType: cryptoCurrencyToWalletType(currency),
|
||||
amount: paymentRequest.amount,
|
||||
note: paymentRequest.note,
|
||||
scheme: paymentRequest.scheme,
|
||||
pjUri: paymentRequest.pjUri,
|
||||
callbackUrl: paymentRequest.callbackUrl,
|
||||
callbackMessage: paymentRequest.callbackMessage,
|
||||
isValid: true,
|
||||
);
|
||||
} catch (e) {
|
||||
return AddressDetectionResult(
|
||||
address: input,
|
||||
detectedCurrency: null,
|
||||
isValid: false,
|
||||
errorMessage: 'Failed to parse URI: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect address from raw address patterns
|
||||
static AddressDetectionResult _detectFromRawAddress(String input) {
|
||||
final cleanInput = input.trim();
|
||||
|
||||
// Detection patterns ordered by specificity (most specific first)
|
||||
final detectionPatterns = [
|
||||
// Lightning Network
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^(lnbc|LNBC)[a-km-zA-HJ-NP-Z1-9]{1,}[a-zA-Z0-9]+$'),
|
||||
currency: CryptoCurrency.btcln,
|
||||
),
|
||||
|
||||
// Bitcoin Bech32
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^bc1[a-km-zA-HJ-NP-Z1-9]{25,39}$'),
|
||||
currency: CryptoCurrency.btc,
|
||||
),
|
||||
|
||||
// Litecoin Bech32
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^ltc1[a-z0-9]{25,50}$'),
|
||||
currency: CryptoCurrency.ltc,
|
||||
),
|
||||
|
||||
// Bitcoin Cash
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^(q|p)[a-z0-9]{41}$'),
|
||||
currency: CryptoCurrency.bch,
|
||||
),
|
||||
|
||||
// Ethereum
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^0x[a-fA-F0-9]{40}$'),
|
||||
currency: CryptoCurrency.eth,
|
||||
),
|
||||
|
||||
// Nano
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^nano_[a-km-zA-HJ-NP-Z1-9]{60}$'),
|
||||
currency: CryptoCurrency.nano,
|
||||
),
|
||||
|
||||
// Banano
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^ban_[a-km-zA-HJ-NP-Z1-9]{60}$'),
|
||||
currency: CryptoCurrency.banano,
|
||||
),
|
||||
|
||||
// Tron
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^T[a-km-zA-HJ-NP-Z1-9]{33}$'),
|
||||
currency: CryptoCurrency.trx,
|
||||
),
|
||||
|
||||
// Zano alias
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^@[\w\d.-]+$'),
|
||||
currency: CryptoCurrency.zano,
|
||||
),
|
||||
|
||||
// Monero (standard) - 95 characters
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^4[a-km-zA-HJ-NP-Z1-9]{94}$'),
|
||||
currency: CryptoCurrency.xmr,
|
||||
),
|
||||
|
||||
// Monero (integrated) - 95 characters
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^8[a-km-zA-HJ-NP-Z1-9]{94}$'),
|
||||
currency: CryptoCurrency.xmr,
|
||||
),
|
||||
|
||||
// Wownero - 97 characters
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^W[a-km-zA-HJ-NP-Z1-9]{96}$'),
|
||||
currency: CryptoCurrency.wow,
|
||||
),
|
||||
|
||||
// Zano (long addresses) - 97 characters
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^Z[a-km-zA-HJ-NP-Z1-9]{96}$'),
|
||||
currency: CryptoCurrency.zano,
|
||||
),
|
||||
|
||||
// Decred
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^(D|T|S)[ksecS][a-km-zA-HJ-NP-Z1-9]+$'),
|
||||
currency: CryptoCurrency.dcr,
|
||||
),
|
||||
|
||||
// Bitcoin P2PKH/P2SH (legacy formats)
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$'),
|
||||
currency: CryptoCurrency.btc,
|
||||
),
|
||||
|
||||
// Litecoin P2PKH/P2SH
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^(L|M|3)[a-km-zA-HJ-NP-Z1-9]{25,34}$'),
|
||||
currency: CryptoCurrency.ltc,
|
||||
),
|
||||
|
||||
// Solana (Base58 format, 32-44 chars)
|
||||
_DetectionPattern(
|
||||
pattern: RegExp(r'^[1-9A-HJ-NP-Za-km-z]{32,44}$'),
|
||||
currency: CryptoCurrency.sol,
|
||||
),
|
||||
];
|
||||
|
||||
// Test each pattern in order of specificity
|
||||
for (final pattern in detectionPatterns) {
|
||||
if (pattern.pattern.hasMatch(cleanInput)) {
|
||||
return AddressDetectionResult(
|
||||
address: cleanInput,
|
||||
detectedCurrency: pattern.currency,
|
||||
detectedWalletType: cryptoCurrencyToWalletType(pattern.currency),
|
||||
isValid: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return AddressDetectionResult(
|
||||
address: cleanInput,
|
||||
detectedCurrency: null,
|
||||
isValid: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetectionPattern {
|
||||
const _DetectionPattern({
|
||||
required this.pattern,
|
||||
required this.currency,
|
||||
});
|
||||
|
||||
final RegExp pattern;
|
||||
final CryptoCurrency currency;
|
||||
}
|
||||
+15
@@ -282,6 +282,8 @@ import 'package:cake_wallet/core/trade_monitor.dart';
|
||||
import 'package:cake_wallet/core/reset_service.dart';
|
||||
import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart';
|
||||
import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart';
|
||||
import 'package:cake_wallet/view_model/payment/payment_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart';
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
@@ -816,6 +818,8 @@ Future<void> setup({
|
||||
sendViewModel: getIt.get<SendViewModel>(param1: coinTypeToSpendFrom),
|
||||
authService: getIt.get<AuthService>(),
|
||||
initialPaymentRequest: initialPaymentRequest,
|
||||
paymentViewModel: getIt.get<PaymentViewModel>(),
|
||||
walletSwitcherViewModel: getIt.get<WalletSwitcherViewModel>(),
|
||||
));
|
||||
|
||||
getIt.registerFactory(
|
||||
@@ -1126,6 +1130,17 @@ Future<void> setup({
|
||||
|
||||
getIt.registerFactory(() => ExchangeTemplatePage(getIt.get<ExchangeViewModel>()));
|
||||
|
||||
getIt.registerFactory(() => PaymentViewModel(
|
||||
appStore: getIt.get<AppStore>(),
|
||||
walletInfoSource: _walletInfoSource,
|
||||
));
|
||||
|
||||
getIt.registerFactory(() => WalletSwitcherViewModel(
|
||||
appStore: getIt.get<AppStore>(),
|
||||
walletLoadingService: getIt.get<WalletLoadingService>(),
|
||||
walletInfoSource: _walletInfoSource,
|
||||
));
|
||||
|
||||
getIt.registerFactoryParam<WalletService, WalletType, void>((WalletType param1, __) {
|
||||
switch (param1) {
|
||||
case WalletType.monero:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cake_wallet/core/universal_address_detector.dart';
|
||||
import 'package:cake_wallet/store/app_store.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
import 'package:cw_core/wallet_info.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
part 'payment_view_model.g.dart';
|
||||
|
||||
class PaymentViewModel = PaymentViewModelBase with _$PaymentViewModel;
|
||||
|
||||
abstract class PaymentViewModelBase with Store {
|
||||
PaymentViewModelBase({
|
||||
required this.appStore,
|
||||
required this.walletInfoSource,
|
||||
});
|
||||
|
||||
final AppStore appStore;
|
||||
final Box<WalletInfo> walletInfoSource;
|
||||
|
||||
@observable
|
||||
WalletType? detectedWalletType;
|
||||
|
||||
@observable
|
||||
bool isProcessing = false;
|
||||
|
||||
@computed
|
||||
WalletType get currentWalletType => appStore.wallet!.type;
|
||||
|
||||
/// Main entry point - detect address type and check compatibility
|
||||
@action
|
||||
Future<PaymentFlowResult> processAddress(String addressData) async {
|
||||
try {
|
||||
detectedWalletType = null;
|
||||
isProcessing = true;
|
||||
|
||||
// Detect address type
|
||||
final detectionResult = UniversalAddressDetector.detectAddress(addressData);
|
||||
|
||||
if (!detectionResult.isValid || detectionResult.detectedWalletType == null) {
|
||||
return PaymentFlowResult.incompatible('Unable to detect address type');
|
||||
}
|
||||
|
||||
detectedWalletType = detectionResult.detectedWalletType!;
|
||||
|
||||
// Check if current wallet is compatible
|
||||
final currentWallet = appStore.wallet;
|
||||
if (currentWallet != null && currentWallet.type == detectedWalletType) {
|
||||
return PaymentFlowResult.currentWalletCompatible();
|
||||
}
|
||||
|
||||
final compatibleWallets = getWalletsByType(detectedWalletType!);
|
||||
|
||||
switch (compatibleWallets.length) {
|
||||
case 0:
|
||||
return PaymentFlowResult.noWallets(detectedWalletType!);
|
||||
case 1:
|
||||
return PaymentFlowResult.singleWallet(compatibleWallets.first);
|
||||
default:
|
||||
return PaymentFlowResult.multipleWallets(compatibleWallets);
|
||||
}
|
||||
} catch (e) {
|
||||
printV('PaymentViewModel error: $e');
|
||||
return PaymentFlowResult.error('Payment processing failed: $e');
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<WalletInfo> getWalletsByType(WalletType walletType) {
|
||||
return walletInfoSource.values.where((wallet) => wallet.type == walletType).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class PaymentFlowResult {
|
||||
final PaymentFlowType type;
|
||||
final String? message;
|
||||
final WalletInfo? wallet;
|
||||
final List<WalletInfo> wallets;
|
||||
final WalletType? walletType;
|
||||
|
||||
PaymentFlowResult._({
|
||||
required this.type,
|
||||
this.message,
|
||||
this.wallet,
|
||||
this.wallets = const [],
|
||||
this.walletType,
|
||||
});
|
||||
|
||||
/// Current wallet is compatible
|
||||
factory PaymentFlowResult.currentWalletCompatible() => PaymentFlowResult._(type: PaymentFlowType.currentWalletCompatible);
|
||||
|
||||
/// Single compatible wallet available
|
||||
factory PaymentFlowResult.singleWallet(WalletInfo wallet) =>
|
||||
PaymentFlowResult._(type: PaymentFlowType.singleWallet, wallet: wallet);
|
||||
|
||||
/// Multiple compatible wallets available
|
||||
factory PaymentFlowResult.multipleWallets(List<WalletInfo> wallets) =>
|
||||
PaymentFlowResult._(type: PaymentFlowType.multipleWallets, wallets: wallets);
|
||||
|
||||
/// No compatible wallets available
|
||||
factory PaymentFlowResult.noWallets(WalletType walletType) =>
|
||||
PaymentFlowResult._(type: PaymentFlowType.noWallets, walletType: walletType);
|
||||
|
||||
/// Error occurred
|
||||
factory PaymentFlowResult.error(String message) =>
|
||||
PaymentFlowResult._(type: PaymentFlowType.error, message: message);
|
||||
|
||||
/// Incompatible address
|
||||
factory PaymentFlowResult.incompatible(String message) =>
|
||||
PaymentFlowResult._(type: PaymentFlowType.incompatible, message: message);
|
||||
}
|
||||
|
||||
enum PaymentFlowType {
|
||||
currentWalletCompatible,
|
||||
singleWallet,
|
||||
multipleWallets,
|
||||
noWallets,
|
||||
error,
|
||||
incompatible,
|
||||
}
|
||||
@@ -37,7 +37,9 @@ import 'package:cake_wallet/utils/payment_request.dart';
|
||||
import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/payment/payment_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/send/fees_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/send/output.dart';
|
||||
import 'package:cake_wallet/view_model/send/send_template_view_model.dart';
|
||||
import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cake_wallet/store/app_store.dart';
|
||||
import 'package:cake_wallet/core/wallet_loading_service.dart';
|
||||
import 'package:cw_core/wallet_info.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
part 'wallet_switcher_view_model.g.dart';
|
||||
|
||||
class WalletSwitcherViewModel = WalletSwitcherViewModelBase with _$WalletSwitcherViewModel;
|
||||
|
||||
abstract class WalletSwitcherViewModelBase with Store {
|
||||
WalletSwitcherViewModelBase({
|
||||
required this.appStore,
|
||||
required this.walletLoadingService,
|
||||
required this.walletInfoSource,
|
||||
});
|
||||
|
||||
final AppStore appStore;
|
||||
final WalletLoadingService walletLoadingService;
|
||||
final Box<WalletInfo> walletInfoSource;
|
||||
|
||||
@observable
|
||||
WalletInfo? selectedWallet;
|
||||
|
||||
@observable
|
||||
bool isProcessing = false;
|
||||
|
||||
@action
|
||||
List<WalletInfo> getWallets(WalletType? walletType) {
|
||||
if (walletType == null) {
|
||||
return walletInfoSource.values.toList();
|
||||
}
|
||||
|
||||
return walletInfoSource.values.where((wallet) => wallet.type == walletType).toList();
|
||||
}
|
||||
|
||||
@action
|
||||
void selectWallet(WalletInfo walletInfo) => selectedWallet = walletInfo;
|
||||
|
||||
@action
|
||||
Future<bool> switchToSelectedWallet() async {
|
||||
if (selectedWallet == null) return false;
|
||||
|
||||
try {
|
||||
isProcessing = true;
|
||||
|
||||
final wallet = await walletLoadingService.load(selectedWallet!.type, selectedWallet!.name);
|
||||
|
||||
await appStore.changeCurrentWallet(wallet);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
printV('Failed to switch wallet: $e');
|
||||
return false;
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user