CW-1409-Update-Cakepay-cards-to-use-all-variants (#3112)

* add prepaid range support for CakePay cards

* use selected denomination for order creation

* fix Flexible widget issue

* forces to use prod api

* auto-reformat

* fix the input string format [skip ci]

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

* fix the value string format [skip ci]

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

* fix the nextamount string format

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

---------

Co-authored-by: Robert Malikowski <malikowskirobert@gmail.com>
Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Serhii
2026-07-15 15:57:59 +03:00
committed by GitHub
co-authored by Omar Hatem Robert Malikowski
parent b1922fc663
commit 6fc27dd3a0
7 changed files with 366 additions and 66 deletions
@@ -8,6 +8,7 @@ import 'package:cake_wallet/cake_pay/src/widgets/denominations_amount_widget.dar
import 'package:cake_wallet/cake_pay/src/widgets/enter_amount_widget.dart';
import 'package:cake_wallet/cake_pay/src/widgets/image_placeholder.dart';
import 'package:cake_wallet/cake_pay/src/widgets/link_extractor.dart';
import 'package:cake_wallet/cake_pay/src/widgets/prepaid_range_amount_widget.dart';
import 'package:cake_wallet/cake_pay/src/widgets/rounded_overlay_cards_widget.dart';
import 'package:cake_wallet/cake_pay/src/widgets/text_icon_button.dart';
import 'package:cake_wallet/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart';
@@ -147,24 +148,38 @@ class CakePayBuyCardPage extends BasePage {
),
bottomCardChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: card.denominationItems.isNotEmpty
? DenominationsAmountWidget(
fiatCurrency: card.fiatCurrency.title,
denominations: card.denominationItems,
amountFieldFocus: _amountFieldFocus,
amountController: _amountController,
quantityFieldFocus: _quantityFieldFocus,
quantityController: _quantityController,
onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
onQuantityChanged: cakePayBuyCardViewModel.onQuantityChanged,
cakePayBuyCardViewModel: cakePayBuyCardViewModel)
: EnterAmountWidget(
minValue: card.minValue ?? '-',
maxValue: card.maxValue ?? '-',
fiatCurrency: card.fiatCurrency.title,
amountFieldFocus: _amountFieldFocus,
amountController: _amountController,
onAmountChanged: cakePayBuyCardViewModel.onAmountChanged))),
child: Column(children: [
if (card.prepaidRange.isNotEmpty)
PrepaidRangeAmountWidget(
fiatCurrency: card.fiatCurrency.title,
prepaidRanges: card.prepaidRange,
amountFieldFocus: _amountFieldFocus,
amountController: _amountController,
cakePayBuyCardViewModel: cakePayBuyCardViewModel,
onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
)
else if (card.denominationItems.isNotEmpty)
DenominationsAmountWidget(
fiatCurrency: card.fiatCurrency.title,
denominations: card.denominationItems,
amountFieldFocus: _amountFieldFocus,
amountController: _amountController,
quantityFieldFocus: _quantityFieldFocus,
quantityController: _quantityController,
onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
onQuantityChanged: cakePayBuyCardViewModel.onQuantityChanged,
cakePayBuyCardViewModel: cakePayBuyCardViewModel,
)
else
EnterAmountWidget(
minValue: card.minValue ?? '-',
maxValue: card.maxValue ?? '-',
fiatCurrency: card.fiatCurrency.title,
amountFieldFocus: _amountFieldFocus,
amountController: _amountController,
onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
),
]))),
Expanded(
flex: 2,
child: Padding(
+108 -21
View File
@@ -1,8 +1,21 @@
import 'package:cake_wallet/core/utilities.dart';
import 'package:cake_wallet/entities/fiat_currency.dart';
enum CakePayCardType {
prepaid('Prepaid Cards'),
gift('gift'),
onDemand('on_demand'),
custom('custom');
const CakePayCardType(this.apiValue);
final String apiValue;
}
class CakePayCard {
final int id;
final String name;
final CakePayCardType? type;
final String? description;
final String? termsAndConditions;
final String? howToUse;
@@ -15,26 +28,77 @@ class CakePayCard {
final String? minValue;
final String? maxValue;
final List<Denomination> denominationItems;
final List<PrepaidRange> prepaidRange;
CakePayCard({
required this.id,
required this.name,
this.description,
this.termsAndConditions,
this.howToUse,
this.expiryAndValidity,
this.cardImageUrl,
this.country,
required this.fiatCurrency,
this.minValueUsd,
this.maxValueUsd,
this.minValue,
this.maxValue,
CakePayCard(
{required this.id,
required this.name,
this.type,
this.description,
this.termsAndConditions,
this.howToUse,
this.expiryAndValidity,
this.cardImageUrl,
this.country,
required this.fiatCurrency,
this.minValueUsd,
this.maxValueUsd,
this.minValue,
this.maxValue,
List<Denomination>? denominationItems,
List<PrepaidRange>? prepaidRange})
: denominationItems = denominationItems ?? const [],
prepaidRange = prepaidRange ?? const [];
CakePayCard copyWith({
int? id,
String? name,
CakePayCardType? type,
String? description,
String? termsAndConditions,
String? howToUse,
String? expiryAndValidity,
String? cardImageUrl,
String? country,
FiatCurrency? fiatCurrency,
String? minValueUsd,
String? maxValueUsd,
String? minValue,
String? maxValue,
List<Denomination>? denominationItems,
}) : denominationItems = denominationItems ?? const [];
List<PrepaidRange>? prepaidRange,
}) {
return CakePayCard(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
description: description ?? this.description,
termsAndConditions: termsAndConditions ?? this.termsAndConditions,
howToUse: howToUse ?? this.howToUse,
expiryAndValidity: expiryAndValidity ?? this.expiryAndValidity,
cardImageUrl: cardImageUrl ?? this.cardImageUrl,
country: country ?? this.country,
fiatCurrency: fiatCurrency ?? this.fiatCurrency,
minValueUsd: minValueUsd ?? this.minValueUsd,
maxValueUsd: maxValueUsd ?? this.maxValueUsd,
minValue: minValue ?? this.minValue,
maxValue: maxValue ?? this.maxValue,
denominationItems: denominationItems ?? this.denominationItems,
prepaidRange: prepaidRange ?? this.prepaidRange,
);
}
static CakePayCardType? cakePayCardTypeFromApi(String? value) {
if (value == null) return null;
final normalized = value.trim().toLowerCase();
return CakePayCardType.values.firstWhereOrNull(
(e) => e.apiValue.toLowerCase() == normalized,
);
}
factory CakePayCard.fromJson(Map<String, dynamic> json) {
final name = stripHtmlIfNeeded(json['name'] as String? ?? '');
final typeString = json['type'] as String? ?? '';
final description = stripHtmlIfNeeded(json['description'] as String? ?? '');
final termsAndConditions = stripHtmlIfNeeded(json['terms_and_conditions'] as String? ?? '');
final howToUse = stripHtmlIfNeeded(json['how_to_use'] as String? ?? '');
@@ -63,9 +127,12 @@ class CakePayCard {
}
}
final cakePayCardType = cakePayCardTypeFromApi(typeString);
return CakePayCard(
id: json['id'] as int? ?? 0,
name: name,
type: cakePayCardType,
description: description,
termsAndConditions: termsAndConditions,
howToUse: howToUse,
@@ -103,12 +170,6 @@ class Denomination {
this.usdValue,
});
static double? _toDouble(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
factory Denomination.fromJson(Map<String, dynamic> json) {
return Denomination(
value: _toDouble(json['value']) ?? 0,
@@ -117,3 +178,29 @@ class Denomination {
);
}
}
class PrepaidRange {
final double minValue;
final double maxValue;
final String rangeId;
PrepaidRange({
required this.minValue,
required this.maxValue,
required this.rangeId,
});
factory PrepaidRange.fromCard(CakePayCard card) {
return PrepaidRange(
minValue: _toDouble(card.minValue) ?? 0,
maxValue: _toDouble(card.maxValue) ?? 0,
rangeId: card.id.toString(),
);
}
}
double? _toDouble(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
+20 -3
View File
@@ -31,9 +31,26 @@ class CakePayVendor {
if (cardsJson != null && cardsJson.isNotEmpty) {
try {
cardForVendor = CakePayCard.fromJson(cardsJson.firstWhere((card) {
return Country.normalizeName(card['country'] as String) == country;
}) as Map<String, dynamic>);
final cards = cardsJson
.map((cardJson) => CakePayCard.fromJson(cardJson as Map<String, dynamic>))
.toList();
final cardsForSelectedCountry =
cards.where((card) => Country.normalizeName(card.country ?? '') == country).toList();
if (cardsForSelectedCountry.isNotEmpty) {
final isPrepaid =
cardsForSelectedCountry.every((card) => card.type == CakePayCardType.prepaid);
if (isPrepaid) {
final firstCard = cardsForSelectedCountry.first;
final prepaidRanges = cardsForSelectedCountry.map(PrepaidRange.fromCard).toList()
..sort((a, b) => a.minValue.compareTo(b.minValue));
cardForVendor = firstCard.copyWith(prepaidRange: prepaidRanges);
} else {
cardForVendor = cardsForSelectedCountry.first;
}
}
} catch (e) {
printV('Error parsing card for vendor: $e');
}
+1 -1
View File
@@ -8,7 +8,7 @@ import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/utils/print_verbose.dart';
class CakePayApi {
static const testBaseUri = FeatureFlag.hasDevOptions;
static const testBaseUri = false; //FeatureFlag.hasDevOptions;
static const baseTestCakePayUri = 'api-stg.cakepay.com';
static const baseProdCakePayUri = 'api-prod.cakepay.com';
@@ -0,0 +1,134 @@
import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart';
import 'package:flutter/material.dart';
import 'enter_amount_widget.dart';
class PrepaidRangeAmountWidget extends StatefulWidget {
const PrepaidRangeAmountWidget({
required this.fiatCurrency,
required this.prepaidRanges,
required this.amountFieldFocus,
required this.amountController,
required this.cakePayBuyCardViewModel,
required this.onAmountChanged,
});
final String fiatCurrency;
final List<PrepaidRange> prepaidRanges;
final FocusNode amountFieldFocus;
final TextEditingController amountController;
final CakePayBuyCardViewModel cakePayBuyCardViewModel;
final Function(String) onAmountChanged;
@override
State<PrepaidRangeAmountWidget> createState() => _PrepaidRangeAmountWidgetState();
}
class _PrepaidRangeAmountWidgetState extends State<PrepaidRangeAmountWidget> {
late PrepaidRange _selectedRange;
@override
void initState() {
super.initState();
_selectedRange = widget.prepaidRanges.first;
_applySelectedRange(forceAmountUpdate: widget.amountController.text.isEmpty);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select range:',
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color!,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<PrepaidRange>(
value: _selectedRange,
isExpanded: true,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
filled: true,
fillColor: Theme.of(context).cardColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.onSurface.withAlpha(40),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
items: widget.prepaidRanges
.map(
(range) => DropdownMenuItem<PrepaidRange>(
value: range,
child: Text(_rangeLabel(range)),
),
)
.toList(),
onChanged: (range) {
if (range == null) return;
setState(() => _selectedRange = range);
widget.cakePayBuyCardViewModel.onPrepaidRangeChanged(range);
_applySelectedRange(forceAmountUpdate: true);
},
),
const SizedBox(height: 12),
EnterAmountWidget(
minValue: _formatAmount(_selectedRange.minValue),
maxValue: _formatAmount(_selectedRange.maxValue),
fiatCurrency: widget.fiatCurrency,
amountFieldFocus: widget.amountFieldFocus,
amountController: widget.amountController,
onAmountChanged: (value) {
widget.onAmountChanged(value);
widget.cakePayBuyCardViewModel.selectedDenomination = (
value.replaceAll(',', '.'),
int.tryParse(_selectedRange.rangeId),
);
},
),
],
);
}
void _applySelectedRange({required bool forceAmountUpdate}) {
final currentAmount = double.tryParse(widget.amountController.text);
final isCurrentAmountInRange = currentAmount != null &&
currentAmount >= _selectedRange.minValue &&
currentAmount <= _selectedRange.maxValue;
final nextAmount = forceAmountUpdate || !isCurrentAmountInRange
? _formatAmount(_selectedRange.minValue)
: widget.amountController.text;
widget.amountController.text = nextAmount;
widget.onAmountChanged(nextAmount);
widget.cakePayBuyCardViewModel.selectedDenomination = (
nextAmount.replaceAll(',', '.'),
int.tryParse(_selectedRange.rangeId),
);
}
String _rangeLabel(PrepaidRange range) {
return '${widget.fiatCurrency} ${_formatAmount(range.minValue)} - ${_formatAmount(range.maxValue)}';
}
String _formatAmount(double value) {
return value % 1 == 0 ? value.toInt().toString() : value.toStringAsFixed(2);
}
}
@@ -12,32 +12,46 @@ class RoundedOverlayCards extends StatelessWidget {
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
return ClipRRect(
borderRadius:
BorderRadius.only(bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)),
child: Container(
height: screenHeight * 0.50,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
),
color: Theme.of(context).colorScheme.surfaceContainer),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)),
Flexible(
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(25.0),
bottomRight: Radius.circular(25.0),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)),
color: Theme.of(context).colorScheme.surfaceContainerLow,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
),
height: screenHeight * 0.38,
width: double.infinity,
child: topCardChild)),
bottomCardChild,
color: Theme.of(context).colorScheme.surfaceContainerLow,
),
constraints: BoxConstraints(
maxHeight: screenHeight * 0.38,
),
width: double.infinity,
child: topCardChild,
),
),
),
Flexible(
child: bottomCardChild,
),
],
),
),
@@ -33,8 +33,12 @@ abstract class CakePayBuyCardViewModelBase with Store {
: 0,
quantity = 1,
card = vendor.card!,
min = _toDouble(vendor.card!.minValue) ?? 0,
max = _toDouble(vendor.card!.maxValue) ?? 0 {
min = vendor.card!.prepaidRange.isNotEmpty
? vendor.card!.prepaidRange.first.minValue
: _toDouble(vendor.card!.minValue) ?? 0,
max = vendor.card!.prepaidRange.isNotEmpty
? vendor.card!.prepaidRange.first.maxValue
: _toDouble(vendor.card!.maxValue) ?? 0 {
selectedPaymentMethod = availableMethods.isNotEmpty ? availableMethods.first : null;
}
@@ -46,8 +50,6 @@ abstract class CakePayBuyCardViewModelBase with Store {
final CakePayVendor vendor;
final SendViewModel sendViewModel;
final CakePayService _cakePayService;
final double max;
final double min;
final CakePayCard card;
final WalletType walletType;
final Box<Order> orders;
@@ -59,6 +61,8 @@ abstract class CakePayBuyCardViewModelBase with Store {
bool confirmsNoVpn = false;
bool confirmsVoidedRefund = false;
bool confirmsTermsAgreed = false;
double max;
double min;
(String, int?) selectedDenomination = ('', null);
String simulatedResponse = '';
@@ -136,10 +140,24 @@ abstract class CakePayBuyCardViewModelBase with Store {
@action
void onQuantityChanged(int? input) => quantity = input ?? 1;
void onPrepaidRangeChanged(PrepaidRange range) {
min = range.minValue;
max = range.maxValue;
selectedDenomination = (range.minValue.toString(), int.tryParse(range.rangeId));
if (amount < min || amount > max) {
amount = min;
}
}
@action
void onAmountChanged(String input) {
if (input.isEmpty) return;
amount = double.parse(input.replaceAll(',', '.'));
if (card.prepaidRange.isNotEmpty) {
selectedDenomination = (input.replaceAll(',', '.'), selectedDenomination.$2);
}
}
@action
@@ -153,10 +171,25 @@ abstract class CakePayBuyCardViewModelBase with Store {
sendViewModel.state =
FailureState('Unsupported wallet type, please use Bitcoin, Monero, Litecoin or Zcash.');
}
final isPrepaidRangeSelected = card.prepaidRange.isNotEmpty;
int selectedCardId = card.id;
String selectedPrice = amount.toString();
if (isPrepaidRangeSelected) {
selectedCardId = selectedDenomination.$2 ?? card.id;
selectedPrice =
selectedDenomination.$1.isNotEmpty ? selectedDenomination.$1 : amount.toString();
} else if (isDenominationSelected) {
selectedCardId = selectedDenomination.$2 ?? card.id;
selectedPrice = selectedDenomination.$1;
}
try {
order = await _cakePayService.createOrder(
cardId: isDenominationSelected ? selectedDenomination.$2 ?? card.id : card.id,
price: isDenominationSelected ? selectedDenomination.$1 : amount.toString(),
cardId: selectedCardId,
price: selectedPrice,
quantity: quantity,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,