diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index b35b2bf09..6c15a9ad1 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -634,10 +634,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1100,10 +1100,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: @@ -1322,5 +1322,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.29.0" diff --git a/cw_core/lib/amount/exchange_rate.dart b/cw_core/lib/amount/exchange_rate.dart new file mode 100644 index 000000000..efcb06abd --- /dev/null +++ b/cw_core/lib/amount/exchange_rate.dart @@ -0,0 +1,40 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/currency.dart'; + +class ExchangeRate { + /// The currency being priced (e.g. BTC in a BTC/USD pair). + final Currency base; + + /// The price of one whole unit of [base], in the quote currency + /// (e.g. 45000 USD in a BTC/USD pair). + final Money quote; + + const ExchangeRate({required this.base, required this.quote}); + + /// Converts [amount] between the base and quote currencies. + /// + /// Results are truncated toward zero. + /// + /// Throws an [ArgumentError] if [amount]'s currency is not part of + /// this pair. + Money convert(Money amount) { + if (base == quote.currency && base == amount.currency) return amount; + + final scale = BigInt.from(10).pow(base.decimals); + + if (amount.currency == base) { + if (quote.isZero) return Money.zero(quote.currency); + + return Money(amount.amount * quote.amount ~/ scale, quote.currency); + } + + if (amount.currency == quote.currency) { + if (quote.isZero) return Money.zero(base); + + return Money(amount.amount * scale ~/ quote.amount, base); + } + + throw ArgumentError( + "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair"); + } +} diff --git a/cw_core/pubspec.lock b/cw_core/pubspec.lock index 8eff1d338..908829bcb 100644 --- a/cw_core/pubspec.lock +++ b/cw_core/pubspec.lock @@ -394,10 +394,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -738,10 +738,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: "direct main" description: @@ -838,5 +838,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/cw_core/test/amount/exchange_rate_test.dart b/cw_core/test/amount/exchange_rate_test.dart new file mode 100644 index 000000000..3873e349f --- /dev/null +++ b/cw_core/test/amount/exchange_rate_test.dart @@ -0,0 +1,147 @@ +import 'package:cw_core/amount/exchange_rate.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ExchangeRate', () { + final rate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000", EUR), + ); + + test('convert base currency to quote currency: 1 BTC to 60.000 EUR', () { + final result = rate.convert(Money.parse('1', CryptoCurrency.btc)); + expect(result, Money.parse("60000.00", EUR)); + }); + + test('convert base currency to quote currency: 0.5 BTC to 30.000 EUR', () { + final result = rate.convert(Money.parse('0.5', CryptoCurrency.btc)); + expect(result, Money.parse("30000.00", EUR)); + }); + + test('convert quote currency to base currency: 60.000 EUR to 1 BTC', () { + final result = rate.convert(Money.parse("60000.00", EUR)); + expect(result, Money.parse('1', CryptoCurrency.btc)); + }); + + test('convert quote currency to base currency: 30 EUR to 0.0005 BTC', () { + final result = rate.convert(Money.parse("30.00", EUR)); + expect(result, Money.parse('0.0005', CryptoCurrency.btc)); + }); + }); + + group('Edge Cases', () { + final rate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000", EUR), + ); + + test('Converting zero turns into zero', () { + expect(rate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR)); + expect(rate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc)); + }); + + test('truncate to smallest unit', () { + final result = rate.convert(Money(BigInt.one, CryptoCurrency.btc)); // 1 sat + expect(result, Money.zero(EUR)); + }); + + test('Truncate base unit of base currency', () { + final result = rate.convert(Money(BigInt.one, EUR)); + expect(result, Money.fromInt(16, CryptoCurrency.btc)); + }); + + test('No int overflow with large numbers', () { + final allBtc = Money.parse('21000000', CryptoCurrency.btc); + final result = rate.convert(allBtc); + expect(result, Money(BigInt.from(126000000000000), EUR)); + }); + + test('truncation for negative amounts', () { + final result = rate.convert(Money.fromInt(-1, EUR)); + expect(result, Money.fromInt(-16, CryptoCurrency.btc)); + }); + + test('Base currency without decimals places: JPY', () { + final rate = ExchangeRate( + base: JPY, + quote: Money.fromInt(1, EUR), + ); + + expect(rate.convert(Money.fromInt(500, JPY)), Money.fromInt(500, EUR)); + expect(rate.convert(Money.fromInt(500, EUR)), Money.fromInt(500, JPY)); + }); + + test('Base currency with 18 decimals places: ETH', () { + final rate = ExchangeRate( + base: CryptoCurrency.eth, + quote: Money.fromInt(300000, EUR), + ); + + expect(rate.convert(Money.parse('1', CryptoCurrency.eth)), Money.fromInt(300000, EUR)); + expect(rate.convert(Money.fromInt(3000, EUR)), Money.parse('0.01', CryptoCurrency.eth)); + }); + + test('Smalles base unit is less than smalles base unit of quote', () { + final rate = ExchangeRate( + base: CryptoCurrency.eth, + quote: Money.fromInt(300000, EUR), + ); + expect(rate.convert(Money(BigInt.one, CryptoCurrency.eth)), Money(BigInt.zero, EUR)); + }); + + test('Cannot convert currency outside of pair', () { + expect(() => rate.convert(Money.parse("60000", USD)), throwsArgumentError); + }); + + test('handle quote being 0 correctly', () { + final zeroRate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money(BigInt.zero, EUR), + ); + expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR)); + expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc)); + }); + }); +} + +class FiatCurrency implements Currency { + const FiatCurrency({ + required this.symbol, + required this.countryCode, + required this.fullName, + this.decimals = 2, + }); + + final String countryCode; + + @override + final String fullName; + + @override + final int decimals; + + @override + final String symbol; + + @override + String? get iconPath => throw UnimplementedError(); + + @override + String get name => throw UnimplementedError(); + + @override + String? get tag => throw UnimplementedError(); + + @override + Money parseAmount(String value) => Money.parse(value, this); + + @override + Money? tryParseAmount(String value) => Money.tryParse(value, this); +} + +const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro"); +const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar"); +const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0); diff --git a/cw_decred/pubspec.lock b/cw_decred/pubspec.lock index 9a568daa7..0b460e9f9 100644 --- a/cw_decred/pubspec.lock +++ b/cw_decred/pubspec.lock @@ -417,10 +417,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -769,10 +769,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: diff --git a/cw_monero/pubspec.lock b/cw_monero/pubspec.lock index 79da36944..b32f56c5b 100644 --- a/cw_monero/pubspec.lock +++ b/cw_monero/pubspec.lock @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -906,10 +906,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: @@ -1048,5 +1048,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.24.0" diff --git a/cw_nano/pubspec.lock b/cw_nano/pubspec.lock index 509d74498..2dd436985 100644 --- a/cw_nano/pubspec.lock +++ b/cw_nano/pubspec.lock @@ -462,10 +462,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -887,10 +887,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: diff --git a/cw_wownero/pubspec.lock b/cw_wownero/pubspec.lock index 4b622b350..7fd7eac2d 100644 --- a/cw_wownero/pubspec.lock +++ b/cw_wownero/pubspec.lock @@ -417,10 +417,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -786,10 +786,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: diff --git a/cw_zano/pubspec.lock b/cw_zano/pubspec.lock index c4602f532..a4a6c5381 100644 --- a/cw_zano/pubspec.lock +++ b/cw_zano/pubspec.lock @@ -422,10 +422,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -783,10 +783,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" torch_dart: dependency: transitive description: 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,