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>

Serhii committed Jul 15, 2026 at 15:57 UTC 6fc27dd3a0fe94cf5b92368b27a0e159aa679134
7 files changed +366 -66
lib/cake_pay/src/cards/cake_pay_buy_card_page.dart
+33 -18
@@ -8,6 +8,7 @@ import 'package:cake_wallet/cake_pay/src/widgets/denominations_amount_widget.dar
8 import 'package:cake_wallet/cake_pay/src/widgets/enter_amount_widget.dart';
9 import 'package:cake_wallet/cake_pay/src/widgets/image_placeholder.dart';
10 import 'package:cake_wallet/cake_pay/src/widgets/link_extractor.dart';
11 +import 'package:cake_wallet/cake_pay/src/widgets/prepaid_range_amount_widget.dart';
12 import 'package:cake_wallet/cake_pay/src/widgets/rounded_overlay_cards_widget.dart';
13 import 'package:cake_wallet/cake_pay/src/widgets/text_icon_button.dart';
14 import 'package:cake_wallet/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart';
@@ -147,24 +148,38 @@ class CakePayBuyCardPage extends BasePage {
148 ),
149 bottomCardChild: Padding(
150 padding: const EdgeInsets.symmetric(horizontal: 24),
150 - child: card.denominationItems.isNotEmpty
151 - ? DenominationsAmountWidget(
152 - fiatCurrency: card.fiatCurrency.title,
153 - denominations: card.denominationItems,
154 - amountFieldFocus: _amountFieldFocus,
155 - amountController: _amountController,
156 - quantityFieldFocus: _quantityFieldFocus,
157 - quantityController: _quantityController,
158 - onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
159 - onQuantityChanged: cakePayBuyCardViewModel.onQuantityChanged,
160 - cakePayBuyCardViewModel: cakePayBuyCardViewModel)
161 - : EnterAmountWidget(
162 - minValue: card.minValue ?? '-',
163 - maxValue: card.maxValue ?? '-',
164 - fiatCurrency: card.fiatCurrency.title,
165 - amountFieldFocus: _amountFieldFocus,
166 - amountController: _amountController,
167 - onAmountChanged: cakePayBuyCardViewModel.onAmountChanged))),
151 + child: Column(children: [
152 + if (card.prepaidRange.isNotEmpty)
153 + PrepaidRangeAmountWidget(
154 + fiatCurrency: card.fiatCurrency.title,
155 + prepaidRanges: card.prepaidRange,
156 + amountFieldFocus: _amountFieldFocus,
157 + amountController: _amountController,
158 + cakePayBuyCardViewModel: cakePayBuyCardViewModel,
159 + onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
160 + )
161 + else if (card.denominationItems.isNotEmpty)
162 + DenominationsAmountWidget(
163 + fiatCurrency: card.fiatCurrency.title,
164 + denominations: card.denominationItems,
165 + amountFieldFocus: _amountFieldFocus,
166 + amountController: _amountController,
167 + quantityFieldFocus: _quantityFieldFocus,
168 + quantityController: _quantityController,
169 + onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
170 + onQuantityChanged: cakePayBuyCardViewModel.onQuantityChanged,
171 + cakePayBuyCardViewModel: cakePayBuyCardViewModel,
172 + )
173 + else
174 + EnterAmountWidget(
175 + minValue: card.minValue ?? '-',
176 + maxValue: card.maxValue ?? '-',
177 + fiatCurrency: card.fiatCurrency.title,
178 + amountFieldFocus: _amountFieldFocus,
179 + amountController: _amountController,
180 + onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
181 + ),
182 + ]))),
183 Expanded(
184 flex: 2,
185 child: Padding(
lib/cake_pay/src/models/cake_pay_card.dart
+108 -21
@@ -1,8 +1,21 @@
1 +import 'package:cake_wallet/core/utilities.dart';
2 import 'package:cake_wallet/entities/fiat_currency.dart';
3
4 +enum CakePayCardType {
5 + prepaid('Prepaid Cards'),
6 + gift('gift'),
7 + onDemand('on_demand'),
8 + custom('custom');
9 +
10 + const CakePayCardType(this.apiValue);
11 +
12 + final String apiValue;
13 +}
14 +
15 class CakePayCard {
16 final int id;
17 final String name;
18 + final CakePayCardType? type;
19 final String? description;
20 final String? termsAndConditions;
21 final String? howToUse;
@@ -15,26 +28,77 @@ class CakePayCard {
28 final String? minValue;
29 final String? maxValue;
30 final List<Denomination> denominationItems;
31 + final List<PrepaidRange> prepaidRange;
32
19 - CakePayCard({
20 - required this.id,
21 - required this.name,
22 - this.description,
23 - this.termsAndConditions,
24 - this.howToUse,
25 - this.expiryAndValidity,
26 - this.cardImageUrl,
27 - this.country,
28 - required this.fiatCurrency,
29 - this.minValueUsd,
30 - this.maxValueUsd,
31 - this.minValue,
32 - this.maxValue,
33 + CakePayCard(
34 + {required this.id,
35 + required this.name,
36 + this.type,
37 + this.description,
38 + this.termsAndConditions,
39 + this.howToUse,
40 + this.expiryAndValidity,
41 + this.cardImageUrl,
42 + this.country,
43 + required this.fiatCurrency,
44 + this.minValueUsd,
45 + this.maxValueUsd,
46 + this.minValue,
47 + this.maxValue,
48 + List<Denomination>? denominationItems,
49 + List<PrepaidRange>? prepaidRange})
50 + : denominationItems = denominationItems ?? const [],
51 + prepaidRange = prepaidRange ?? const [];
52 +
53 + CakePayCard copyWith({
54 + int? id,
55 + String? name,
56 + CakePayCardType? type,
57 + String? description,
58 + String? termsAndConditions,
59 + String? howToUse,
60 + String? expiryAndValidity,
61 + String? cardImageUrl,
62 + String? country,
63 + FiatCurrency? fiatCurrency,
64 + String? minValueUsd,
65 + String? maxValueUsd,
66 + String? minValue,
67 + String? maxValue,
68 List<Denomination>? denominationItems,
34 - }) : denominationItems = denominationItems ?? const [];
69 + List<PrepaidRange>? prepaidRange,
70 + }) {
71 + return CakePayCard(
72 + id: id ?? this.id,
73 + name: name ?? this.name,
74 + type: type ?? this.type,
75 + description: description ?? this.description,
76 + termsAndConditions: termsAndConditions ?? this.termsAndConditions,
77 + howToUse: howToUse ?? this.howToUse,
78 + expiryAndValidity: expiryAndValidity ?? this.expiryAndValidity,
79 + cardImageUrl: cardImageUrl ?? this.cardImageUrl,
80 + country: country ?? this.country,
81 + fiatCurrency: fiatCurrency ?? this.fiatCurrency,
82 + minValueUsd: minValueUsd ?? this.minValueUsd,
83 + maxValueUsd: maxValueUsd ?? this.maxValueUsd,
84 + minValue: minValue ?? this.minValue,
85 + maxValue: maxValue ?? this.maxValue,
86 + denominationItems: denominationItems ?? this.denominationItems,
87 + prepaidRange: prepaidRange ?? this.prepaidRange,
88 + );
89 + }
90 +
91 + static CakePayCardType? cakePayCardTypeFromApi(String? value) {
92 + if (value == null) return null;
93 + final normalized = value.trim().toLowerCase();
94 + return CakePayCardType.values.firstWhereOrNull(
95 + (e) => e.apiValue.toLowerCase() == normalized,
96 + );
97 + }
98
99 factory CakePayCard.fromJson(Map<String, dynamic> json) {
100 final name = stripHtmlIfNeeded(json['name'] as String? ?? '');
101 + final typeString = json['type'] as String? ?? '';
102 final description = stripHtmlIfNeeded(json['description'] as String? ?? '');
103 final termsAndConditions = stripHtmlIfNeeded(json['terms_and_conditions'] as String? ?? '');
104 final howToUse = stripHtmlIfNeeded(json['how_to_use'] as String? ?? '');
@@ -63,9 +127,12 @@ class CakePayCard {
127 }
128 }
129
130 + final cakePayCardType = cakePayCardTypeFromApi(typeString);
131 +
132 return CakePayCard(
133 id: json['id'] as int? ?? 0,
134 name: name,
135 + type: cakePayCardType,
136 description: description,
137 termsAndConditions: termsAndConditions,
138 howToUse: howToUse,
@@ -103,12 +170,6 @@ class Denomination {
170 this.usdValue,
171 });
172
106 - static double? _toDouble(dynamic v) {
107 - if (v == null) return null;
108 - if (v is num) return v.toDouble();
109 - return double.tryParse(v.toString());
110 - }
111 -
173 factory Denomination.fromJson(Map<String, dynamic> json) {
174 return Denomination(
175 value: _toDouble(json['value']) ?? 0,
@@ -117,3 +178,29 @@ class Denomination {
178 );
179 }
180 }
181 +
182 +class PrepaidRange {
183 + final double minValue;
184 + final double maxValue;
185 + final String rangeId;
186 +
187 + PrepaidRange({
188 + required this.minValue,
189 + required this.maxValue,
190 + required this.rangeId,
191 + });
192 +
193 + factory PrepaidRange.fromCard(CakePayCard card) {
194 + return PrepaidRange(
195 + minValue: _toDouble(card.minValue) ?? 0,
196 + maxValue: _toDouble(card.maxValue) ?? 0,
197 + rangeId: card.id.toString(),
198 + );
199 + }
200 +}
201 +
202 +double? _toDouble(dynamic v) {
203 + if (v == null) return null;
204 + if (v is num) return v.toDouble();
205 + return double.tryParse(v.toString());
206 +}
lib/cake_pay/src/models/cake_pay_vendor.dart
+20 -3
@@ -31,9 +31,26 @@ class CakePayVendor {
31
32 if (cardsJson != null && cardsJson.isNotEmpty) {
33 try {
34 - cardForVendor = CakePayCard.fromJson(cardsJson.firstWhere((card) {
35 - return Country.normalizeName(card['country'] as String) == country;
36 - }) as Map<String, dynamic>);
34 + final cards = cardsJson
35 + .map((cardJson) => CakePayCard.fromJson(cardJson as Map<String, dynamic>))
36 + .toList();
37 + final cardsForSelectedCountry =
38 + cards.where((card) => Country.normalizeName(card.country ?? '') == country).toList();
39 +
40 + if (cardsForSelectedCountry.isNotEmpty) {
41 + final isPrepaid =
42 + cardsForSelectedCountry.every((card) => card.type == CakePayCardType.prepaid);
43 +
44 + if (isPrepaid) {
45 + final firstCard = cardsForSelectedCountry.first;
46 + final prepaidRanges = cardsForSelectedCountry.map(PrepaidRange.fromCard).toList()
47 + ..sort((a, b) => a.minValue.compareTo(b.minValue));
48 +
49 + cardForVendor = firstCard.copyWith(prepaidRange: prepaidRanges);
50 + } else {
51 + cardForVendor = cardsForSelectedCountry.first;
52 + }
53 + }
54 } catch (e) {
55 printV('Error parsing card for vendor: $e');
56 }
lib/cake_pay/src/services/cake_pay_api.dart
+1 -1
@@ -8,7 +8,7 @@ import 'package:cw_core/utils/proxy_wrapper.dart';
8 import 'package:cw_core/utils/print_verbose.dart';
9
10 class CakePayApi {
11 - static const testBaseUri = FeatureFlag.hasDevOptions;
11 + static const testBaseUri = false; //FeatureFlag.hasDevOptions;
12
13 static const baseTestCakePayUri = 'api-stg.cakepay.com';
14 static const baseProdCakePayUri = 'api-prod.cakepay.com';
lib/cake_pay/src/widgets/prepaid_range_amount_widget.dart new
+134
@@ -0,0 +1,134 @@
1 +import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart';
2 +import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart';
3 +import 'package:flutter/material.dart';
4 +
5 +import 'enter_amount_widget.dart';
6 +
7 +class PrepaidRangeAmountWidget extends StatefulWidget {
8 + const PrepaidRangeAmountWidget({
9 + required this.fiatCurrency,
10 + required this.prepaidRanges,
11 + required this.amountFieldFocus,
12 + required this.amountController,
13 + required this.cakePayBuyCardViewModel,
14 + required this.onAmountChanged,
15 + });
16 +
17 + final String fiatCurrency;
18 + final List<PrepaidRange> prepaidRanges;
19 + final FocusNode amountFieldFocus;
20 + final TextEditingController amountController;
21 + final CakePayBuyCardViewModel cakePayBuyCardViewModel;
22 + final Function(String) onAmountChanged;
23 +
24 + @override
25 + State<PrepaidRangeAmountWidget> createState() => _PrepaidRangeAmountWidgetState();
26 +}
27 +
28 +class _PrepaidRangeAmountWidgetState extends State<PrepaidRangeAmountWidget> {
29 + late PrepaidRange _selectedRange;
30 +
31 + @override
32 + void initState() {
33 + super.initState();
34 + _selectedRange = widget.prepaidRanges.first;
35 + _applySelectedRange(forceAmountUpdate: widget.amountController.text.isEmpty);
36 + }
37 +
38 + @override
39 + Widget build(BuildContext context) {
40 + return Column(
41 + crossAxisAlignment: CrossAxisAlignment.start,
42 + children: [
43 + Text(
44 + 'Select range:',
45 + style: TextStyle(
46 + color: Theme.of(context).textTheme.titleLarge!.color!,
47 + fontSize: 16,
48 + fontWeight: FontWeight.w600,
49 + ),
50 + ),
51 + const SizedBox(height: 8),
52 + DropdownButtonFormField<PrepaidRange>(
53 + value: _selectedRange,
54 + isExpanded: true,
55 + decoration: InputDecoration(
56 + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
57 + filled: true,
58 + fillColor: Theme.of(context).cardColor,
59 + border: OutlineInputBorder(
60 + borderRadius: BorderRadius.circular(10),
61 + ),
62 + enabledBorder: OutlineInputBorder(
63 + borderRadius: BorderRadius.circular(10),
64 + borderSide: BorderSide(
65 + color: Theme.of(context).colorScheme.onSurface.withAlpha(40),
66 + ),
67 + ),
68 + focusedBorder: OutlineInputBorder(
69 + borderRadius: BorderRadius.circular(10),
70 + borderSide: BorderSide(
71 + color: Theme.of(context).colorScheme.primary,
72 + ),
73 + ),
74 + ),
75 + items: widget.prepaidRanges
76 + .map(
77 + (range) => DropdownMenuItem<PrepaidRange>(
78 + value: range,
79 + child: Text(_rangeLabel(range)),
80 + ),
81 + )
82 + .toList(),
83 + onChanged: (range) {
84 + if (range == null) return;
85 + setState(() => _selectedRange = range);
86 + widget.cakePayBuyCardViewModel.onPrepaidRangeChanged(range);
87 + _applySelectedRange(forceAmountUpdate: true);
88 + },
89 + ),
90 + const SizedBox(height: 12),
91 + EnterAmountWidget(
92 + minValue: _formatAmount(_selectedRange.minValue),
93 + maxValue: _formatAmount(_selectedRange.maxValue),
94 + fiatCurrency: widget.fiatCurrency,
95 + amountFieldFocus: widget.amountFieldFocus,
96 + amountController: widget.amountController,
97 + onAmountChanged: (value) {
98 + widget.onAmountChanged(value);
99 + widget.cakePayBuyCardViewModel.selectedDenomination = (
100 + value.replaceAll(',', '.'),
101 + int.tryParse(_selectedRange.rangeId),
102 + );
103 + },
104 + ),
105 + ],
106 + );
107 + }
108 +
109 + void _applySelectedRange({required bool forceAmountUpdate}) {
110 + final currentAmount = double.tryParse(widget.amountController.text);
111 + final isCurrentAmountInRange = currentAmount != null &&
112 + currentAmount >= _selectedRange.minValue &&
113 + currentAmount <= _selectedRange.maxValue;
114 +
115 + final nextAmount = forceAmountUpdate || !isCurrentAmountInRange
116 + ? _formatAmount(_selectedRange.minValue)
117 + : widget.amountController.text;
118 +
119 + widget.amountController.text = nextAmount;
120 + widget.onAmountChanged(nextAmount);
121 + widget.cakePayBuyCardViewModel.selectedDenomination = (
122 + nextAmount.replaceAll(',', '.'),
123 + int.tryParse(_selectedRange.rangeId),
124 + );
125 + }
126 +
127 + String _rangeLabel(PrepaidRange range) {
128 + return '${widget.fiatCurrency} ${_formatAmount(range.minValue)} - ${_formatAmount(range.maxValue)}';
129 + }
130 +
131 + String _formatAmount(double value) {
132 + return value % 1 == 0 ? value.toInt().toString() : value.toStringAsFixed(2);
133 + }
134 +}
lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart
+31 -17
@@ -12,32 +12,46 @@ class RoundedOverlayCards extends StatelessWidget {
12 @override
13 Widget build(BuildContext context) {
14 final screenHeight = MediaQuery.of(context).size.height;
15 +
16 return ClipRRect(
17 borderRadius:
18 BorderRadius.only(bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)),
19 child: Container(
19 - height: screenHeight * 0.50,
20 decoration: BoxDecoration(
21 - borderRadius: BorderRadius.only(
22 - bottomLeft: Radius.circular(24),
23 - bottomRight: Radius.circular(24),
24 - ),
25 - color: Theme.of(context).colorScheme.surfaceContainer),
21 + borderRadius: const BorderRadius.only(
22 + bottomLeft: Radius.circular(24),
23 + bottomRight: Radius.circular(24),
24 + ),
25 + color: Theme.of(context).colorScheme.surfaceContainer,
26 + ),
27 child: Column(
28 + mainAxisSize: MainAxisSize.min,
29 children: [
28 - ClipRRect(
29 - borderRadius: BorderRadius.only(
30 - bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)),
30 + Flexible(
31 + child: ClipRRect(
32 + borderRadius: const BorderRadius.only(
33 + bottomLeft: Radius.circular(25.0),
34 + bottomRight: Radius.circular(25.0),
35 + ),
36 child: Container(
32 - decoration: BoxDecoration(
33 - borderRadius: BorderRadius.only(
34 - bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)),
35 - color: Theme.of(context).colorScheme.surfaceContainerLow,
37 + decoration: BoxDecoration(
38 + borderRadius: const BorderRadius.only(
39 + bottomLeft: Radius.circular(24),
40 + bottomRight: Radius.circular(24),
41 ),
37 - height: screenHeight * 0.38,
38 - width: double.infinity,
39 - child: topCardChild)),
40 - bottomCardChild,
42 + color: Theme.of(context).colorScheme.surfaceContainerLow,
43 + ),
44 + constraints: BoxConstraints(
45 + maxHeight: screenHeight * 0.38,
46 + ),
47 + width: double.infinity,
48 + child: topCardChild,
49 + ),
50 + ),
51 + ),
52 + Flexible(
53 + child: bottomCardChild,
54 + ),
55 ],
56 ),
57 ),
lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart
+39 -6
@@ -33,8 +33,12 @@ abstract class CakePayBuyCardViewModelBase with Store {
33 : 0,
34 quantity = 1,
35 card = vendor.card!,
36 - min = _toDouble(vendor.card!.minValue) ?? 0,
37 - max = _toDouble(vendor.card!.maxValue) ?? 0 {
36 + min = vendor.card!.prepaidRange.isNotEmpty
37 + ? vendor.card!.prepaidRange.first.minValue
38 + : _toDouble(vendor.card!.minValue) ?? 0,
39 + max = vendor.card!.prepaidRange.isNotEmpty
40 + ? vendor.card!.prepaidRange.first.maxValue
41 + : _toDouble(vendor.card!.maxValue) ?? 0 {
42 selectedPaymentMethod = availableMethods.isNotEmpty ? availableMethods.first : null;
43 }
44
@@ -46,8 +50,6 @@ abstract class CakePayBuyCardViewModelBase with Store {
50 final CakePayVendor vendor;
51 final SendViewModel sendViewModel;
52 final CakePayService _cakePayService;
49 - final double max;
50 - final double min;
53 final CakePayCard card;
54 final WalletType walletType;
55 final Box<Order> orders;
@@ -59,6 +61,8 @@ abstract class CakePayBuyCardViewModelBase with Store {
61 bool confirmsNoVpn = false;
62 bool confirmsVoidedRefund = false;
63 bool confirmsTermsAgreed = false;
64 + double max;
65 + double min;
66 (String, int?) selectedDenomination = ('', null);
67
68 String simulatedResponse = '';
@@ -136,10 +140,24 @@ abstract class CakePayBuyCardViewModelBase with Store {
140 @action
141 void onQuantityChanged(int? input) => quantity = input ?? 1;
142
143 + void onPrepaidRangeChanged(PrepaidRange range) {
144 + min = range.minValue;
145 + max = range.maxValue;
146 + selectedDenomination = (range.minValue.toString(), int.tryParse(range.rangeId));
147 +
148 + if (amount < min || amount > max) {
149 + amount = min;
150 + }
151 + }
152 +
153 @action
154 void onAmountChanged(String input) {
155 if (input.isEmpty) return;
156 amount = double.parse(input.replaceAll(',', '.'));
157 +
158 + if (card.prepaidRange.isNotEmpty) {
159 + selectedDenomination = (input.replaceAll(',', '.'), selectedDenomination.$2);
160 + }
161 }
162
163 @action
@@ -153,10 +171,25 @@ abstract class CakePayBuyCardViewModelBase with Store {
171 sendViewModel.state =
172 FailureState('Unsupported wallet type, please use Bitcoin, Monero, Litecoin or Zcash.');
173 }
174 +
175 + final isPrepaidRangeSelected = card.prepaidRange.isNotEmpty;
176 +
177 + int selectedCardId = card.id;
178 + String selectedPrice = amount.toString();
179 +
180 + if (isPrepaidRangeSelected) {
181 + selectedCardId = selectedDenomination.$2 ?? card.id;
182 + selectedPrice =
183 + selectedDenomination.$1.isNotEmpty ? selectedDenomination.$1 : amount.toString();
184 + } else if (isDenominationSelected) {
185 + selectedCardId = selectedDenomination.$2 ?? card.id;
186 + selectedPrice = selectedDenomination.$1;
187 + }
188 +
189 try {
190 order = await _cakePayService.createOrder(
158 - cardId: isDenominationSelected ? selectedDenomination.$2 ?? card.id : card.id,
159 - price: isDenominationSelected ? selectedDenomination.$1 : amount.toString(),
191 + cardId: selectedCardId,
192 + price: selectedPrice,
193 quantity: quantity,
194 confirmsNoVpn: confirmsNoVpn,
195 confirmsVoidedRefund: confirmsVoidedRefund,