feat: support LTC and MWEB payments

This commit is contained in:
Serhii 2025-06-16 16:12:08 +03:00
parent 2086c467b5
commit acd73d83c4
3 changed files with 179 additions and 51 deletions

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_alert_modal.dart';
import 'package:cake_wallet/cake_pay/src/widgets/denominations_amount_widget.dart';
@ -90,7 +91,6 @@ class CakePayBuyCardPage extends BasePage {
final card = cakePayBuyCardViewModel.card;
final vendor = cakePayBuyCardViewModel.vendor;
final baseTitleColor = titleColor(context);
return KeyboardActions(
disableScroll: true,
@ -248,25 +248,89 @@ class CakePayBuyCardPage extends BasePage {
),
SizedBox(height: 8),
Observer(builder: (_) {
return Padding(
padding: EdgeInsets.only(top: 10, bottom: 34, right: 20, left: 20),
child: LoadingPrimaryButton(
onPressed: () {
//Request dummy node to get the focus out of the text fields
FocusScope.of(context).requestFocus(FocusNode());
final methods = cakePayBuyCardViewModel.availableMethods;
final selected = cakePayBuyCardViewModel.selectedPaymentMethod ?? methods.first;
isIOSUnavailable(card)
? alertIOSAvailability(context, card)
: confirmPurchaseFirst(context);
},
text: S.of(context).purchase_gift_card,
isDisabled: !cakePayBuyCardViewModel.isAmountSufficient ||
cakePayBuyCardViewModel.isPurchasing,
isLoading: cakePayBuyCardViewModel.sendViewModel.state is IsExecutingState ||
cakePayBuyCardViewModel.isPurchasing,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
return Column(
children: [
methods.length <= 1
? const SizedBox.shrink()
: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 24, right: 8),
child: Text(
'Payment Method',
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color!,
fontSize: 16,
fontWeight: FontWeight.w900,
),
),
),
Expanded(child: const SizedBox()),
if (methods.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: ToggleButtons(
isSelected: methods.map((m) => m == selected).toList(),
borderRadius: BorderRadius.circular(8),
onPressed: (index) =>
cakePayBuyCardViewModel.chooseMethod(methods[index]),
children: methods
.map((m) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(m.label),
))
.toList(),
),
),
],
),
if (FeatureFlag.hasDevOptions)
Padding(
padding: EdgeInsets.only(top: 10, bottom: 0, right: 20, left: 20),
child: LoadingPrimaryButton(
onPressed: () {
//Request dummy node to get the focus out of the text fields
FocusScope.of(context).requestFocus(FocusNode());
cakePayBuyCardViewModel.isSimulatingFlow = true;
isIOSUnavailable(card)
? alertIOSAvailability(context, card)
: confirmPurchaseFirst(context);
},
text: '(Dev) Simulate Purchasing Gift Card',
isDisabled: !cakePayBuyCardViewModel.isAmountSufficient ||
cakePayBuyCardViewModel.isPurchasing,
isLoading: cakePayBuyCardViewModel.sendViewModel.state is IsExecutingState ||
cakePayBuyCardViewModel.isPurchasing,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
Padding(
padding: EdgeInsets.only(top: 10, bottom: 34, right: 20, left: 20),
child: LoadingPrimaryButton(
onPressed: () {
//Request dummy node to get the focus out of the text fields
FocusScope.of(context).requestFocus(FocusNode());
isIOSUnavailable(card)
? alertIOSAvailability(context, card)
: confirmPurchaseFirst(context);
},
text: S.of(context).purchase_gift_card,
isDisabled: !cakePayBuyCardViewModel.isAmountSufficient ||
cakePayBuyCardViewModel.isPurchasing,
isLoading: cakePayBuyCardViewModel.sendViewModel.state is IsExecutingState ||
cakePayBuyCardViewModel.isPurchasing,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
);
}),
],
@ -317,6 +381,7 @@ class CakePayBuyCardPage extends BasePage {
}
Future<void> alertIOSAvailability(BuildContext context, CakePayCard card) async {
cakePayBuyCardViewModel.isSimulatingFlow = false;
return await showPopUp<void>(
context: context,
builder: (BuildContext context) {
@ -334,11 +399,13 @@ class CakePayBuyCardPage extends BasePage {
Future<void> confirmPurchaseFirst(BuildContext context) async {
bool isLogged = await cakePayBuyCardViewModel.cakePayService.isLogged();
if (!isLogged) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
Navigator.of(context).pushNamed(Routes.cakePayWelcomePage);
} else {
cakePayBuyCardViewModel.isPurchasing = true;
await _showconfirmPurchaseFirstAlert(context);
}
}
Future<void> _showconfirmPurchaseFirstAlert(BuildContext context) async {
@ -353,6 +420,7 @@ class CakePayBuyCardPage extends BasePage {
rightButtonText: S.of(context).confirm,
actionLeftButton: () {
cakePayBuyCardViewModel.isPurchasing = false;
cakePayBuyCardViewModel.isSimulatingFlow = false;
Navigator.of(context).pop();
},
actionRightButton: (confirmsNoVpn, confirmsVoidedRefund, confirmsTermsAgreed) {
@ -370,17 +438,22 @@ class CakePayBuyCardPage extends BasePage {
cakePayBuyCardViewModel.confirmsVoidedRefund &&
cakePayBuyCardViewModel.confirmsTermsAgreed) {
await purchaseCard(context);
} else {
cakePayBuyCardViewModel.isPurchasing = false;
cakePayBuyCardViewModel.isSimulatingFlow = false;
}
}
Future<void> purchaseCard(BuildContext context) async {
bool isLogged = await cakePayBuyCardViewModel.cakePayService.isLogged();
if (!isLogged) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
Navigator.of(context).pushNamed(Routes.cakePayWelcomePage);
} else {
try {
await cakePayBuyCardViewModel.createOrder();
} catch (_) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
await cakePayBuyCardViewModel.cakePayService.logout();
}
}
@ -512,11 +585,11 @@ class CakePayBuyCardPage extends BasePage {
.sendViewModel.pendingTransactionFeeFiatAmountFormatted,
outputs: displayingOutputs,
footerType: FooterType.slideActionButton,
slideActionButtonText: 'Swipe to send',
accessibleNavigationModeSlideActionButtonText: S.of(context).send,
slideActionButtonText: cakePayBuyCardViewModel.isSimulating ? 'Swipe to simulate' : 'Swipe to send',
accessibleNavigationModeSlideActionButtonText: cakePayBuyCardViewModel.isSimulating ? 'Simulate' : S.of(context).send,
onSlideActionComplete: () async {
Navigator.of(bottomSheetContext).pop(true);
FeatureFlag.hasDevOptions
cakePayBuyCardViewModel.isSimulating
? cakePayBuyCardViewModel.simulatePayment()
: cakePayBuyCardViewModel.sendViewModel.commitTransaction(context);
},
@ -527,6 +600,7 @@ class CakePayBuyCardPage extends BasePage {
);
confirmBottomSheetContext = null;
cakePayBuyCardViewModel.isSimulatingFlow = false;
_handleDispose(disposer);
if (result == null) cakePayBuyCardViewModel.sendViewModel.dismissTransaction();
}
@ -563,9 +637,7 @@ class CakePayBuyCardPage extends BasePage {
children: [
Text(
textAlign: TextAlign.center,
FeatureFlag.hasDevOptions
? cakePayBuyCardViewModel.simulatedResponse
: S.of(bottomSheetContext).cake_pay_save_order,
S.of(bottomSheetContext).cake_pay_save_order,
style: TextStyle(
fontSize: 12,
fontFamily: 'Lato',

View file

@ -1,3 +1,15 @@
enum CakePayPaymentMethod { BTC, BTC_LN, XMR, LTC, LTC_MWEB }
extension CakePayPaymentMethodLabel on CakePayPaymentMethod {
String get label => switch (this) {
CakePayPaymentMethod.BTC => 'Bitcoin',
CakePayPaymentMethod.BTC_LN => 'Bitcoin Lightning',
CakePayPaymentMethod.XMR => 'Monero',
CakePayPaymentMethod.LTC => 'Litecoin',
CakePayPaymentMethod.LTC_MWEB => 'Litecoin MWEB',
};
}
class CakePayOrder {
final String orderId;
final List<OrderCard> cards;
@ -65,14 +77,20 @@ class OrderCard {
class PaymentData {
final CryptoPaymentData btc;
final CryptoPaymentData btc_ln;
final CryptoPaymentData xmr;
final CryptoPaymentData ltc;
final CryptoPaymentData ltc_mweb;
final DateTime invoiceTime;
final DateTime expirationTime;
final int? commission;
PaymentData({
required this.btc,
required this.btc_ln,
required this.xmr,
required this.ltc,
required this.ltc_mweb,
required this.invoiceTime,
required this.expirationTime,
required this.commission,
@ -81,7 +99,10 @@ class PaymentData {
factory PaymentData.fromMap(Map<String, dynamic> map) {
return PaymentData(
btc: CryptoPaymentData.fromMap(map['BTC'] as Map<String, dynamic>),
btc_ln: CryptoPaymentData.fromMap(map['BTC_LN'] as Map<String, dynamic>),
xmr: CryptoPaymentData.fromMap(map['XMR'] as Map<String, dynamic>),
ltc: CryptoPaymentData.fromMap(map['LTC'] as Map<String, dynamic>),
ltc_mweb: CryptoPaymentData.fromMap(map['LTC_MWEB'] as Map<String, dynamic>),
invoiceTime: DateTime.fromMillisecondsSinceEpoch(map['invoice_time'] as int),
expirationTime: DateTime.fromMillisecondsSinceEpoch(map['expiration_time'] as int),
commission: map['commission'] as int?,

View file

@ -5,6 +5,7 @@ import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart';
import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/view_model/send/send_view_model.dart';
import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
import 'package:cw_core/wallet_type.dart';
@ -24,7 +25,9 @@ abstract class CakePayBuyCardViewModelBase with Store {
quantity = 1,
min = double.parse(vendor.card!.minValue ?? '0'),
max = double.parse(vendor.card!.maxValue ?? '0'),
card = vendor.card!;
card = vendor.card! {
selectedPaymentMethod = availableMethods.isNotEmpty ? availableMethods.first : null;
}
final CakePayVendor vendor;
final SendViewModel sendViewModel;
@ -55,6 +58,9 @@ abstract class CakePayBuyCardViewModelBase with Store {
@observable
bool isPurchasing = false;
@observable
bool isSimulatingFlow = false;
@observable
bool isOrderExpired = false;
@ -65,9 +71,32 @@ abstract class CakePayBuyCardViewModelBase with Store {
bool get isAmountSufficient =>
(amount >= min && amount <= max) || (isDenominationSelected && quantity > 0);
@observable
CakePayPaymentMethod? selectedPaymentMethod;
@computed
double get totalAmount => amount * quantity;
@computed
bool get isSimulating => isSimulatingFlow && FeatureFlag.hasDevOptions;
@computed
List<CakePayPaymentMethod> get availableMethods {
switch (walletType) {
case WalletType.bitcoin:
return [CakePayPaymentMethod.BTC];
case WalletType.litecoin:
return [CakePayPaymentMethod.LTC, CakePayPaymentMethod.LTC_MWEB];
case WalletType.monero:
return [CakePayPaymentMethod.XMR];
default:
return const [];
}
}
@action
void chooseMethod(CakePayPaymentMethod method) => selectedPaymentMethod = method;
@action
void onQuantityChanged(int? input) => quantity = input ?? 1;
@ -77,34 +106,39 @@ abstract class CakePayBuyCardViewModelBase with Store {
amount = double.parse(input.replaceAll(',', '.'));
}
CryptoPaymentData? get cryptoPaymentData {
if (order == null) return null;
CryptoPaymentData? getPaymentDataFor(CakePayPaymentMethod? method) {
if (order == null || method == null) return null;
if (WalletType.monero == walletType) {
return order!.paymentData.xmr;
final data = switch (method) {
CakePayPaymentMethod.BTC => order?.paymentData.btc,
CakePayPaymentMethod.XMR => order?.paymentData.xmr,
CakePayPaymentMethod.LTC => order?.paymentData.ltc,
CakePayPaymentMethod.LTC_MWEB => order?.paymentData.ltc_mweb,
_ => null
};
if (data == null) return null;
final bip21 = data.paymentUrls?.bip21;
if (bip21 != null && bip21.isNotEmpty) {
final uri = Uri.parse(bip21);
final addr = uri.path;
final price = uri.queryParameters['amount'] ?? data.price;
return CryptoPaymentData(price: price, address: addr);
}
if (WalletType.bitcoin == walletType) {
final paymentUrls = order!.paymentData.btc.paymentUrls!.bip21;
final uri = Uri.parse(paymentUrls!);
final address = uri.path;
final price = uri.queryParameters['amount'];
return CryptoPaymentData(
address: address,
price: price ?? '0',
);
}
return null;
return data;
}
@action
Future<void> createOrder() async {
if (walletType != WalletType.bitcoin && walletType != WalletType.monero) {
sendViewModel.state = FailureState('Unsupported wallet type, please use Bitcoin or Monero.');
if (walletType != WalletType.bitcoin &&
walletType != WalletType.monero &&
walletType != WalletType.litecoin) {
sendViewModel.state =
FailureState('Unsupported wallet type, please use Bitcoin, Monero, or Litecoin.');
}
try {
order = await cakePayService.createOrder(
@ -129,10 +163,10 @@ abstract class CakePayBuyCardViewModelBase with Store {
@action
Future<void> confirmSending() async {
final cryptoPaymentData = this.cryptoPaymentData;
try {
if (order == null || cryptoPaymentData == null) return;
final cryptoPaymentData = getPaymentDataFor(selectedPaymentMethod);
if (order == null || cryptoPaymentData == null) return;
try {
sendViewModel.clearOutputs();
final output = sendViewModel.outputs.first;
output.address = cryptoPaymentData.address;
@ -190,7 +224,8 @@ abstract class CakePayBuyCardViewModelBase with Store {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds
.toString().padLeft(2, '0')}';
}
void disposeExpirationTimer() {