From 6fc27dd3a0fe94cf5b92368b27a0e159aa679134 Mon Sep 17 00:00:00 2001 From: Serhii <17529954+serhii-bor@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:57:59 +0300 Subject: [PATCH] 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 * fix the value string format [skip ci] Co-authored-by: Omar Hatem * fix the nextamount string format Co-authored-by: Omar Hatem --------- Co-authored-by: Robert Malikowski Co-authored-by: Omar Hatem --- .../src/cards/cake_pay_buy_card_page.dart | 51 ++++--- lib/cake_pay/src/models/cake_pay_card.dart | 129 ++++++++++++++--- lib/cake_pay/src/models/cake_pay_vendor.dart | 23 ++- lib/cake_pay/src/services/cake_pay_api.dart | 2 +- .../widgets/prepaid_range_amount_widget.dart | 134 ++++++++++++++++++ .../widgets/rounded_overlay_cards_widget.dart | 48 ++++--- .../cake_pay_buy_card_view_model.dart | 45 +++++- 7 files changed, 366 insertions(+), 66 deletions(-) create mode 100644 lib/cake_pay/src/widgets/prepaid_range_amount_widget.dart diff --git a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart index cbb8deceb..d10233a34 100644 --- a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart +++ b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart @@ -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( diff --git a/lib/cake_pay/src/models/cake_pay_card.dart b/lib/cake_pay/src/models/cake_pay_card.dart index 97a73c224..ea37a310e 100644 --- a/lib/cake_pay/src/models/cake_pay_card.dart +++ b/lib/cake_pay/src/models/cake_pay_card.dart @@ -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 denominationItems; + final List 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? denominationItems, + List? 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? denominationItems, - }) : denominationItems = denominationItems ?? const []; + List? 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 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 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()); +} diff --git a/lib/cake_pay/src/models/cake_pay_vendor.dart b/lib/cake_pay/src/models/cake_pay_vendor.dart index 2f2f865d4..c31fad0b0 100644 --- a/lib/cake_pay/src/models/cake_pay_vendor.dart +++ b/lib/cake_pay/src/models/cake_pay_vendor.dart @@ -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); + final cards = cardsJson + .map((cardJson) => CakePayCard.fromJson(cardJson as Map)) + .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'); } diff --git a/lib/cake_pay/src/services/cake_pay_api.dart b/lib/cake_pay/src/services/cake_pay_api.dart index 13aa1b7d5..84cc1f4df 100644 --- a/lib/cake_pay/src/services/cake_pay_api.dart +++ b/lib/cake_pay/src/services/cake_pay_api.dart @@ -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'; diff --git a/lib/cake_pay/src/widgets/prepaid_range_amount_widget.dart b/lib/cake_pay/src/widgets/prepaid_range_amount_widget.dart new file mode 100644 index 000000000..7914e893e --- /dev/null +++ b/lib/cake_pay/src/widgets/prepaid_range_amount_widget.dart @@ -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 prepaidRanges; + final FocusNode amountFieldFocus; + final TextEditingController amountController; + final CakePayBuyCardViewModel cakePayBuyCardViewModel; + final Function(String) onAmountChanged; + + @override + State createState() => _PrepaidRangeAmountWidgetState(); +} + +class _PrepaidRangeAmountWidgetState extends State { + 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( + 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( + 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); + } +} diff --git a/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart b/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart index fecf11b93..9df2848f8 100644 --- a/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart +++ b/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart @@ -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, + ), ], ), ), diff --git a/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart b/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart index b3393da63..a95e2353d 100644 --- a/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_buy_card_view_model.dart @@ -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 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,