mirror of
https://github.com/cake-tech/cake_wallet.git
synced 2025-06-28 12:29:51 +00:00
feat: support LTC and MWEB payments
This commit is contained in:
parent
2086c467b5
commit
acd73d83c4
3 changed files with 179 additions and 51 deletions
|
@ -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',
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue