Cw 973 in app gift card redemption flow plus UI (#2285)

* refactor: cleanup cake_pay folder

* refactor: add My Cards tab on the main screen

* ui: add user card item

* refactor: cakePay purchase flow

* feat: add user card info

* refactor: auth flow

* refactor: theme data

* revert

* Update qr_widget.dart

* fix: update with new themes

* minor fixes

* feat: put redemption flow under a feature flag

* feat: implement simulated purchasing flow in debug mode

* feat: support LTC and MWEB payments

* Update savings_page.dart

* fix: UI fixes

* add Cake Pay transaction sent bottom sheet

* localization

* Update cake_pay_buy_card_page.dart

* addressing review comments

* fix marge conflicts
This commit is contained in:
Serhii
2025-07-11 23:56:35 +03:00
committed by GitHub
parent 203aff83f2
commit ef84db5874
96 changed files with 3907 additions and 2638 deletions
@@ -1,4 +1,4 @@
import 'package:cake_wallet/cake_pay/cake_pay_service.dart';
import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:mobx/mobx.dart';
part 'cake_pay_account_view_model.g.dart';
@@ -1,5 +1,5 @@
import 'package:cake_wallet/cake_pay/cake_pay_states.dart';
import 'package:cake_wallet/cake_pay/cake_pay_service.dart';
import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart';
import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:mobx/mobx.dart';
part 'cake_pay_auth_view_model.g.dart';
@@ -1,5 +1,14 @@
import 'package:cake_wallet/cake_pay/cake_pay_card.dart';
import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart';
import 'dart:async';
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/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';
import 'package:mobx/mobx.dart';
part 'cake_pay_buy_card_view_model.g.dart';
@@ -7,20 +16,36 @@ part 'cake_pay_buy_card_view_model.g.dart';
class CakePayBuyCardViewModel = CakePayBuyCardViewModelBase with _$CakePayBuyCardViewModel;
abstract class CakePayBuyCardViewModelBase with Store {
CakePayBuyCardViewModelBase({required this.vendor})
: amount = vendor.card!.denominations.isNotEmpty
CakePayBuyCardViewModelBase(
{required this.vendor, required this.cakePayService, required this.sendViewModel})
: walletType = sendViewModel.walletType,
amount = vendor.card!.denominations.isNotEmpty
? double.parse(vendor.card!.denominations.first)
: 0,
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 CakePayCard card;
final double min;
final SendViewModel sendViewModel;
final CakePayService cakePayService;
final double max;
final double min;
final CakePayCard card;
final WalletType walletType;
CakePayOrder? order;
Timer? _timer;
DateTime? expirationTime;
Duration? remainingTime;
bool confirmsNoVpn = false;
bool confirmsVoidedRefund = false;
bool confirmsTermsAgreed = false;
String simulatedResponse = '';
bool get isDenominationSelected => card.denominations.isNotEmpty;
@@ -30,13 +55,48 @@ abstract class CakePayBuyCardViewModelBase with Store {
@observable
int quantity;
@observable
bool isPurchasing = false;
@observable
bool isSimulatingFlow = false;
@observable
bool isOrderExpired = false;
@observable
String formattedRemainingTime = '';
@computed
bool get isEnablePurchase =>
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;
@@ -45,4 +105,135 @@ abstract class CakePayBuyCardViewModelBase with Store {
if (input.isEmpty) return;
amount = double.parse(input.replaceAll(',', '.'));
}
CryptoPaymentData? getPaymentDataFor(CakePayPaymentMethod? method) {
if (order == null || method == null) return null;
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);
}
return data;
}
@action
Future<void> createOrder() async {
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(
cardId: card.id,
price: amount.toString(),
quantity: quantity,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,
confirmsTermsAgreed: confirmsTermsAgreed,
);
await confirmSending();
expirationTime = order!.paymentData.expirationTime;
updateRemainingTime();
_startExpirationTimer();
} catch (e) {
sendViewModel.state = FailureState(
sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency));
}
}
@action
Future<void> confirmSending() async {
final cryptoPaymentData = getPaymentDataFor(selectedPaymentMethod);
if (order == null || cryptoPaymentData == null) return;
try {
sendViewModel.clearOutputs();
final output = sendViewModel.outputs.first;
output.address = cryptoPaymentData.address;
output.setCryptoAmount(cryptoPaymentData.price);
await sendViewModel.createTransaction();
} catch (e) {
throw e;
}
}
@action
Future<void> simulatePayment() async {
if (order == null) {
throw Exception('Order is not created yet.');
}
try {
simulatedResponse = await cakePayService.simulatePayment(orderId: order!.orderId);
sendViewModel.state = TransactionCommitted();
} catch (e) {
sendViewModel.state = FailureState(
sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency));
}
}
@action
void updateRemainingTime() {
if (expirationTime == null) {
formattedRemainingTime = '';
return;
}
remainingTime = expirationTime!.difference(DateTime.now());
isOrderExpired = remainingTime!.isNegative;
if (isOrderExpired) {
disposeExpirationTimer();
sendViewModel.state = FailureState('Order has expired.');
} else {
formattedRemainingTime = formatDuration(remainingTime!);
}
}
void _startExpirationTimer() {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: 1), (_) {
updateRemainingTime();
});
}
String formatDuration(Duration duration) {
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')}';
}
void disposeExpirationTimer() {
_timer?.cancel();
remainingTime = null;
formattedRemainingTime = '';
expirationTime = null;
}
void dispose() {
disposeExpirationTimer();
}
}
@@ -1,6 +1,9 @@
import 'package:cake_wallet/cake_pay/cake_pay_service.dart';
import 'package:cake_wallet/cake_pay/cake_pay_states.dart';
import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart';
import 'dart:async';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart';
import 'package:cake_wallet/cake_pay/src/models/cake_pay_vendor.dart';
import 'package:cake_wallet/entities/country.dart';
import 'package:cake_wallet/entities/fiat_currency.dart';
import 'package:cake_wallet/generated/i18n.dart';
@@ -17,8 +20,8 @@ abstract class CakePayCardsListViewModelBase with Store {
CakePayCardsListViewModelBase({
required this.cakePayService,
required this.settingsStore,
}) : cardState = CakePayCardsStateNoCards(),
cakePayVendors = [],
}) : cakePayVendors = [],
userCards = [],
availableCountries = [],
page = 1,
displayPrepaidCards = true,
@@ -28,8 +31,11 @@ abstract class CakePayCardsListViewModelBase with Store {
scrollOffsetFromTop = 0.0,
vendorsState = InitialCakePayVendorLoadingState(),
createCardState = CakePayCreateCardState(),
userCardState = UserCakePayCardsStateInitial(),
searchString = '',
searchMyCardsString = '',
CakePayVendorList = <CakePayVendor>[] {
checkAuth();
initialization();
}
@@ -43,6 +49,7 @@ abstract class CakePayCardsListViewModelBase with Store {
void initialization() async {
await getCountries();
getVendors();
getUserCards();
}
final CakePayService cakePayService;
@@ -51,7 +58,7 @@ abstract class CakePayCardsListViewModelBase with Store {
List<CakePayVendor> CakePayVendorList;
Map<String, List<FilterItem>> get createFilterItems => {
S.current.filter_by: [
'Card Type': [
FilterItem(
value: () => displayPrepaidCards,
caption: S.current.prepaid_cards,
@@ -74,6 +81,7 @@ abstract class CakePayCardsListViewModelBase with Store {
};
String searchString;
String? username;
int page;
late Country _initialSelectedCountry;
@@ -82,6 +90,12 @@ abstract class CakePayCardsListViewModelBase with Store {
late bool _initialDisplayDenominationsCards;
late bool _initialDisplayCustomValueCards;
@observable
List<CakePayCard> userCards;
@observable
String searchMyCardsString;
@observable
double scrollOffsetFromTop;
@@ -89,7 +103,7 @@ abstract class CakePayCardsListViewModelBase with Store {
CakePayCreateCardState createCardState;
@observable
CakePayCardsState cardState;
UserCakePayCardsState userCardState;
@observable
CakePayVendorState vendorsState;
@@ -118,12 +132,29 @@ abstract class CakePayCardsListViewModelBase with Store {
@observable
bool displayCustomValueCards;
@observable
ObservableFuture<bool>? authFuture;
@computed
bool? get isUserAuthenticated =>
authFuture?.status == FutureStatus.fulfilled ? authFuture?.value : null;
@computed
Country get selectedCountry =>
settingsStore.selectedCakePayCountry ?? _getInitialCountry(settingsStore.fiatCurrency);
@computed
bool get shouldShowCountryPicker => settingsStore.selectedCakePayCountry == null && availableCountries.isNotEmpty;
bool get shouldShowCountryPicker =>
settingsStore.selectedCakePayCountry == null && availableCountries.isNotEmpty;
@computed
List<CakePayCard> get filteredUserCards {
final query = searchMyCardsString.trim().toLowerCase();
if (query.isEmpty) return userCards;
return userCards
.where((card) => card.name.toLowerCase().contains(query))
.toList(growable: false);
}
bool get hasFiltersChanged {
@@ -134,7 +165,6 @@ abstract class CakePayCardsListViewModelBase with Store {
displayCustomValueCards != _initialDisplayCustomValueCards;
}
Future<void> getCountries() async {
try {
availableCountries = await cakePayService.getCountries();
@@ -143,6 +173,44 @@ abstract class CakePayCardsListViewModelBase with Store {
}
}
Future<void> getUserCards() async {
//Dummy user cards // TODO: fetch from API
userCardState = UserCakePayCardsStateFetching();
try {
await Future.delayed(const Duration(seconds: 2));
final vendorsCard = cakePayVendors
.where((vendor) => vendor.card != null)
.map((vendor) => vendor.card!)
.toList();
userCards = vendorsCard.sublist(0, 10);
vendorsCard.forEach((card) {
if (card.name.toLowerCase().contains('amazon.com')) {
userCards.add(card);
}
});
userCardState = UserCakePayCardsStateSuccess();
if (userCards.isEmpty) {
userCardState = UserCakePayCardsStateNoCards();
}
} catch (e) {
userCardState = UserCakePayCardsStateFailure(
error: e.toString(),
);
}
}
@action
Future<void> checkAuth() async {
authFuture = ObservableFuture(cakePayService.isLogged());
final logged = await authFuture!;
if (logged) {
username = await cakePayService.getUserEmail();
}
}
@action
Future<void> getVendors({
String? text,
@@ -219,6 +287,9 @@ abstract class CakePayCardsListViewModelBase with Store {
settingsStore.selectedCakePayCountry = country;
}
@action
void setMyCardsQuery(String text) => searchMyCardsString = text;
@action
void togglePrepaidCards() => displayPrepaidCards = !displayPrepaidCards;
@@ -1,173 +0,0 @@
import 'dart:async';
import 'package:cake_wallet/cake_pay/cake_pay_card.dart';
import 'package:cake_wallet/cake_pay/cake_pay_order.dart';
import 'package:cake_wallet/cake_pay/cake_pay_payment_credantials.dart';
import 'package:cake_wallet/cake_pay/cake_pay_service.dart';
import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/view_model/send/send_view_model.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:mobx/mobx.dart';
part 'cake_pay_purchase_view_model.g.dart';
class CakePayPurchaseViewModel = CakePayPurchaseViewModelBase with _$CakePayPurchaseViewModel;
abstract class CakePayPurchaseViewModelBase with Store {
CakePayPurchaseViewModelBase({
required this.cakePayService,
required this.paymentCredential,
required this.card,
required this.sendViewModel,
}) : walletType = sendViewModel.walletType;
final WalletType walletType;
final PaymentCredential paymentCredential;
final CakePayCard card;
final SendViewModel sendViewModel;
final CakePayService cakePayService;
CakePayOrder? order;
Timer? _timer;
DateTime? expirationTime;
Duration? remainingTime;
String? get userName => paymentCredential.userName;
double get amount => paymentCredential.amount;
int get quantity => paymentCredential.quantity;
double get totalAmount => paymentCredential.totalAmount;
String get fiatCurrency => paymentCredential.fiatCurrency;
bool confirmsNoVpn = false;
bool confirmsVoidedRefund = false;
bool confirmsTermsAgreed = false;
@observable
bool isPurchasing = false;
CryptoPaymentData? get cryptoPaymentData {
if (order == null) return null;
if (WalletType.monero == walletType) {
return order!.paymentData.xmr;
}
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;
}
@observable
bool isOrderExpired = false;
@observable
String formattedRemainingTime = '';
@action
Future<void> createOrder() async {
if (walletType != WalletType.bitcoin && walletType != WalletType.monero) {
sendViewModel.state = FailureState('Unsupported wallet type, please use Bitcoin or Monero.');
}
try {
order = await cakePayService.createOrder(
cardId: card.id,
price: paymentCredential.amount.toString(),
quantity: paymentCredential.quantity,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,
confirmsTermsAgreed: confirmsTermsAgreed,
);
await confirmSending();
expirationTime = order!.paymentData.expirationTime;
updateRemainingTime();
_startExpirationTimer();
} catch (e) {
sendViewModel.state = FailureState(
sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency));
}
}
@action
Future<void> confirmSending() async {
final cryptoPaymentData = this.cryptoPaymentData;
try {
if (order == null || cryptoPaymentData == null) return;
sendViewModel.clearOutputs();
final output = sendViewModel.outputs.first;
output.address = cryptoPaymentData.address;
output.setCryptoAmount(cryptoPaymentData.price);
await sendViewModel.createTransaction();
} catch (e) {
throw e;
}
}
@action
void updateRemainingTime() {
if (expirationTime == null) {
formattedRemainingTime = '';
return;
}
remainingTime = expirationTime!.difference(DateTime.now());
isOrderExpired = remainingTime!.isNegative;
if (isOrderExpired) {
disposeExpirationTimer();
sendViewModel.state = FailureState('Order has expired.');
} else {
formattedRemainingTime = formatDuration(remainingTime!);
}
}
void _startExpirationTimer() {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: 1), (_) {
updateRemainingTime();
});
}
String formatDuration(Duration duration) {
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')}';
}
void disposeExpirationTimer() {
_timer?.cancel();
remainingTime = null;
formattedRemainingTime = '';
expirationTime = null;
}
void dispose() {
disposeExpirationTimer();
}
}
@@ -1,4 +1,4 @@
import 'package:cake_wallet/cake_pay/cake_pay_service.dart';
import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:mobx/mobx.dart';
part 'cake_features_view_model.g.dart';
@@ -1,67 +1,57 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
class DropdownFilterList extends StatefulWidget {
DropdownFilterList({
class DropdownFilterList extends StatelessWidget {
const DropdownFilterList({
Key? key,
required this.items,
this.itemPrefix,
this.textStyle,
required this.caption,
required this.selectedItem,
required this.onItemSelected,
this.itemPrefix,
}) : super(key: key);
final List<String> items;
final String? itemPrefix;
final TextStyle? textStyle;
final String caption;
final String selectedItem;
final Function(String) onItemSelected;
@override
_DropdownFilterListState createState() => _DropdownFilterListState();
}
class _DropdownFilterListState extends State<DropdownFilterList> {
String? selectedValue;
@override
void initState() {
super.initState();
selectedValue = widget.selectedItem;
}
final String? itemPrefix;
final ValueChanged<String> onItemSelected;
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
child: Container(
child: DropdownButton<String>(
isExpanded: true,
icon: Container(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Icon(Icons.arrow_drop_down, color: Theme.of(context).colorScheme.onSurfaceVariant),
],
),
),
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHighest,
child: DropdownButton<String>(
isDense: true,
dropdownColor: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(10),
items: widget.items
.map((item) => DropdownMenuItem<String>(
alignment: Alignment.bottomCenter,
value: item,
child: AutoSizeText('${widget.itemPrefix ?? ''} $item', style: widget.textStyle),
))
selectedItemBuilder: (context) => items
.map(
(item) => Text(
'${itemPrefix ?? ''} $item',
style: Theme.of(context).textTheme.titleMedium!,
maxLines: 1,
),
)
.toList(),
value: selectedValue,
onChanged: (newValue) {
setState(() => selectedValue = newValue);
widget.onItemSelected(newValue!);
items: items
.map(
(item) => DropdownMenuItem<String>(
value: item,
alignment: Alignment.centerLeft,
child: Text(
'${itemPrefix ?? ''} $item',
style: (const TextStyle()).copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
maxLines: 1,
),
),
)
.toList(),
value: selectedItem,
onChanged: (value) {
if (value != null) onItemSelected(value);
},
),
),
icon: Icon(Icons.keyboard_arrow_down_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
);
}
}
+26
View File
@@ -335,3 +335,29 @@ abstract class OutputBase with Store {
note = parsedAddress.description;
}
}
extension OutputCopyWith on Output {
Output OutputCopyWithParsedAddress({
ParsedAddress? parsedAddress,
String? fiatAmount,
}) {
final clone = Output(
_wallet,
_settingsStore,
_fiatConversationStore,
cryptoCurrencyHandler,
);
clone
..cryptoAmount = cryptoAmount
..cryptoFullBalance = cryptoFullBalance
..note = note
..sendAll = sendAll
..memo = memo
..stealthAddress = stealthAddress
..parsedAddress = parsedAddress ?? this.parsedAddress
..fiatAmount = fiatAmount ?? this.fiatAmount;
return clone;
}
}