From 17e7d29687caa3d014d0fe79d176b18a6e033767 Mon Sep 17 00:00:00 2001 From: Ethan Lee <125412902+ethan-tbd@users.noreply.github.com> Date: Mon, 6 May 2024 09:56:37 -0700 Subject: [PATCH] feat: update fee details (#146) --- lib/features/payin/deposit_page.dart | 12 +- lib/features/payin/payin.dart | 2 +- lib/features/payment/review_payment_page.dart | 52 ++--- lib/features/payout/payout.dart | 18 +- lib/features/payout/withdraw_page.dart | 12 +- lib/features/remittance/remittance_page.dart | 12 +- lib/l10n/app_en.arb | 16 +- lib/l10n/app_localizations.dart | 42 +++++ lib/l10n/app_localizations_en.dart | 23 +++ lib/shared/fee_details.dart | 177 +++++++++++------- test/features/payin/payin_test.dart | 4 +- .../payment/review_payment_page_test.dart | 6 +- test/features/payout/payout_test.dart | 2 +- test/shared/fee_details_test.dart | 73 +++++--- 14 files changed, 274 insertions(+), 177 deletions(-) diff --git a/lib/features/payin/deposit_page.dart b/lib/features/payin/deposit_page.dart index 7bd9c1e1..d5a23f8f 100644 --- a/lib/features/payin/deposit_page.dart +++ b/lib/features/payin/deposit_page.dart @@ -69,16 +69,8 @@ class DepositPage extends HookConsumerWidget { ), const SizedBox(height: Grid.xl), FeeDetails( - payinCurrency: selectedOffering - .value?.data.payin.currencyCode ?? - '', - payoutCurrency: selectedOffering - .value?.data.payout.currencyCode ?? - '', - exchangeRate: selectedOffering - .value?.data.payoutUnitsPerPayinUnit ?? - '', - serviceFee: '0', + transactionType: TransactionType.deposit, + offering: selectedOffering.value?.data, ), ], ), diff --git a/lib/features/payin/payin.dart b/lib/features/payin/payin.dart index 0cf5ca1a..e365c046 100644 --- a/lib/features/payin/payin.dart +++ b/lib/features/payin/payin.dart @@ -161,7 +161,7 @@ class Payin extends HookWidget { Widget _buildPayinLabel(BuildContext context) { final style = Theme.of(context).textTheme.bodyLarge; final labels = { - TransactionType.deposit: Loc.of(context).youDeposit, + TransactionType.deposit: Loc.of(context).youPay, TransactionType.withdraw: Loc.of(context).youWithdraw, TransactionType.send: Loc.of(context).youSend, }; diff --git a/lib/features/payment/review_payment_page.dart b/lib/features/payment/review_payment_page.dart index 14e88d7d..2c6f3408 100644 --- a/lib/features/payment/review_payment_page.dart +++ b/lib/features/payment/review_payment_page.dart @@ -64,7 +64,7 @@ class ReviewPaymentPage extends HookConsumerWidget { ), ), ), - _buildSubmitButton(context), + _buildSubmitButton(context, quote.data), ], ) : _loading(), @@ -136,7 +136,10 @@ class ReviewPaymentPage extends HookConsumerWidget { ], ), const SizedBox(height: Grid.xxs), - _buildPayinLabel(context), + Text( + Loc.of(context).requestedAmount, + style: Theme.of(context).textTheme.bodySmall, + ), const SizedBox(height: Grid.sm), Row( crossAxisAlignment: CrossAxisAlignment.baseline, @@ -161,26 +164,12 @@ class ReviewPaymentPage extends HookConsumerWidget { ], ); - Widget _buildPayinLabel(BuildContext context) { - final style = Theme.of(context).textTheme.bodySmall; - final labels = { - TransactionType.deposit: Loc.of(context).youPay, - TransactionType.withdraw: Loc.of(context).withdrawAmount, - TransactionType.send: Loc.of(context).youSend, - }; - - final label = - labels[paymentState.transactionType] ?? 'unknown transaction type'; - - return Text(label, style: style); - } - Widget _buildPayoutLabel(BuildContext context) { final style = Theme.of(context).textTheme.bodySmall; final labels = { - TransactionType.deposit: Loc.of(context).depositAmount, - TransactionType.withdraw: Loc.of(context).youGet, - TransactionType.send: Loc.of(context).theyGet, + TransactionType.deposit: Loc.of(context).totalToAccount, + TransactionType.withdraw: Loc.of(context).totalToYou, + TransactionType.send: Loc.of(context).totalToRecipient, }; final label = @@ -189,25 +178,11 @@ class ReviewPaymentPage extends HookConsumerWidget { return Text(label, style: style); } - // TODO(ethan-tbd): clean up this widget, https://github.com/TBD54566975/didpay/issues/143 Widget _buildFeeDetails(BuildContext context, QuoteData quote) => Padding( padding: const EdgeInsets.symmetric(vertical: Grid.lg), child: FeeDetails( - payinCurrency: Loc.of(context).usd, - payoutCurrency: quote.payin.currencyCode != Loc.of(context).usd - ? quote.payin.currencyCode - : quote.payout.currencyCode, - exchangeRate: paymentState.exchangeRate, - serviceFee: double.parse(quote.payout.fee ?? '0').toStringAsFixed(2), - total: paymentState.transactionType == TransactionType.deposit - ? (double.parse( - quote.payin.amount.replaceAll(',', ''), - ) - - double.parse(quote.payin.fee ?? '0')) - .toStringAsFixed(2) - : (double.parse(quote.payout.amount.replaceAll(',', '')) - - double.parse(quote.payout.fee ?? '0')) - .toStringAsFixed(2), + transactionType: paymentState.transactionType, + quote: quote, ), ); @@ -236,7 +211,8 @@ class ReviewPaymentPage extends HookConsumerWidget { ), ); - Widget _buildSubmitButton(BuildContext context) => FilledButton( + Widget _buildSubmitButton(BuildContext context, QuoteData quote) => + FilledButton( onPressed: () { Navigator.of(context).push( MaterialPageRoute( @@ -244,6 +220,8 @@ class ReviewPaymentPage extends HookConsumerWidget { ), ); }, - child: Text(Loc.of(context).submit), + child: Text( + '${Loc.of(context).pay} ${FeeDetails.calculateTotalAmount(quote)} ${quote.payin.currencyCode}', + ), ); } diff --git a/lib/features/payout/payout.dart b/lib/features/payout/payout.dart index 19b3118b..4bb2dda0 100644 --- a/lib/features/payout/payout.dart +++ b/lib/features/payout/payout.dart @@ -97,10 +97,16 @@ class Payout extends HookWidget { } } - Widget _buildPayoutLabel(BuildContext context) => Text( - transactionType == TransactionType.send - ? Loc.of(context).theyGet - : Loc.of(context).youGet, - style: Theme.of(context).textTheme.bodyLarge, - ); + Widget _buildPayoutLabel(BuildContext context) { + final style = Theme.of(context).textTheme.bodyLarge; + final labels = { + TransactionType.deposit: Loc.of(context).youDeposit, + TransactionType.withdraw: Loc.of(context).youGet, + TransactionType.send: Loc.of(context).theyGet, + }; + + final label = labels[transactionType] ?? 'unknown transaction type'; + + return Text(label, style: style); + } } diff --git a/lib/features/payout/withdraw_page.dart b/lib/features/payout/withdraw_page.dart index 63fabfc8..1d5c8d9b 100644 --- a/lib/features/payout/withdraw_page.dart +++ b/lib/features/payout/withdraw_page.dart @@ -69,16 +69,8 @@ class WithdrawPage extends HookConsumerWidget { ), const SizedBox(height: Grid.xl), FeeDetails( - payinCurrency: selectedOffering - .value?.data.payin.currencyCode ?? - '', - payoutCurrency: selectedOffering - .value?.data.payout.currencyCode ?? - '', - exchangeRate: selectedOffering - .value?.data.payoutUnitsPerPayinUnit ?? - '', - serviceFee: '0', + transactionType: TransactionType.withdraw, + offering: selectedOffering.value?.data, ), ], ), diff --git a/lib/features/remittance/remittance_page.dart b/lib/features/remittance/remittance_page.dart index be826199..be0a295f 100644 --- a/lib/features/remittance/remittance_page.dart +++ b/lib/features/remittance/remittance_page.dart @@ -75,16 +75,8 @@ class RemittancePage extends HookConsumerWidget { ), const SizedBox(height: Grid.xl), FeeDetails( - payinCurrency: selectedOffering - .value?.data.payin.currencyCode ?? - '', - payoutCurrency: selectedOffering - .value?.data.payout.currencyCode ?? - '', - exchangeRate: selectedOffering - .value?.data.payoutUnitsPerPayinUnit ?? - '', - serviceFee: '0', + transactionType: TransactionType.send, + offering: selectedOffering.value?.data, ), ], ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 08c1ebce..e02e217e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -100,5 +100,19 @@ "youSend": "You send", "theyGet": "They get", "sendMoneyAbroad": "Send money abroad", - "selectCountryToGetStarted": "Select a country to get started" + "selectCountryToGetStarted": "Select a country to get started", + "exchangeRate": "Exchange rate", + "txnTypeFee": "{txnType} fee", + "@txnTypeFee": { + "placeholders": { + "txnType": { + "type": "String" + } + } + }, + "requestedAmount": "Requested amount", + "totalToAccount": "Total to account", + "totalToYou": "Total to you", + "totalToRecipient": "Total to recipient", + "pay": "Pay" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cba3fc2c..486c18a2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -558,6 +558,48 @@ abstract class Loc { /// In en, this message translates to: /// **'Select a country to get started'** String get selectCountryToGetStarted; + + /// No description provided for @exchangeRate. + /// + /// In en, this message translates to: + /// **'Exchange rate'** + String get exchangeRate; + + /// No description provided for @txnTypeFee. + /// + /// In en, this message translates to: + /// **'{txnType} fee'** + String txnTypeFee(String txnType); + + /// No description provided for @requestedAmount. + /// + /// In en, this message translates to: + /// **'Requested amount'** + String get requestedAmount; + + /// No description provided for @totalToAccount. + /// + /// In en, this message translates to: + /// **'Total to account'** + String get totalToAccount; + + /// No description provided for @totalToYou. + /// + /// In en, this message translates to: + /// **'Total to you'** + String get totalToYou; + + /// No description provided for @totalToRecipient. + /// + /// In en, this message translates to: + /// **'Total to recipient'** + String get totalToRecipient; + + /// No description provided for @pay. + /// + /// In en, this message translates to: + /// **'Pay'** + String get pay; } class _LocDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 84de13cb..df86e7e5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -243,4 +243,27 @@ class LocEn extends Loc { @override String get selectCountryToGetStarted => 'Select a country to get started'; + + @override + String get exchangeRate => 'Exchange rate'; + + @override + String txnTypeFee(String txnType) { + return '$txnType fee'; + } + + @override + String get requestedAmount => 'Requested amount'; + + @override + String get totalToAccount => 'Total to account'; + + @override + String get totalToYou => 'Total to you'; + + @override + String get totalToRecipient => 'Total to recipient'; + + @override + String get pay => 'Pay'; } diff --git a/lib/shared/fee_details.dart b/lib/shared/fee_details.dart index 8542dec4..024df416 100644 --- a/lib/shared/fee_details.dart +++ b/lib/shared/fee_details.dart @@ -1,109 +1,154 @@ +import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/l10n/app_localizations.dart'; import 'package:didpay/shared/theme/grid.dart'; +import 'package:didpay/shared/utils/currency_util.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:tbdex/tbdex.dart'; class FeeDetails extends HookWidget { - final String payinCurrency; - final String payoutCurrency; - final String exchangeRate; - final String serviceFee; - final String total; + final TransactionType transactionType; + final OfferingData? offering; + final QuoteData? quote; const FeeDetails({ - required this.payinCurrency, - required this.payoutCurrency, - required this.exchangeRate, - required this.serviceFee, - this.total = '', + required this.transactionType, + this.offering, + this.quote, super.key, }); @override Widget build(BuildContext context) { + final paymentDetails = offering ?? quote; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(15), ), - padding: const EdgeInsets.all(Grid.xs), + padding: + const EdgeInsets.only(left: Grid.xs, right: Grid.xs, top: Grid.xs), child: Column( children: [ - Row( - children: [ - Expanded( - child: Text( - Loc.of(context).estRate, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - Expanded( - flex: 2, - child: Text( - '1 $payinCurrency = $exchangeRate $payoutCurrency', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - textAlign: TextAlign.right, - ), - ), - ], + _buildRow( + context, + quote != null + ? Loc.of(context).exchangeRate + : Loc.of(context).estRate, + '1 ${paymentDetails.payinCurrency} = ${paymentDetails.exchangeRate} ${paymentDetails.payoutCurrency}', ), - const SizedBox(height: Grid.xs), + if (quote != null || transactionType == TransactionType.deposit) + _buildRow( + context, + transactionType == TransactionType.deposit + ? Loc.of(context).txnTypeFee('$transactionType') + : Loc.of(context).serviceFee, + '${paymentDetails.payoutFee} ${paymentDetails.payoutCurrency}', + ), + if (quote != null || transactionType != TransactionType.deposit) + _buildRow( + context, + transactionType == TransactionType.deposit + ? Loc.of(context).serviceFee + : '$transactionType fee', + '${paymentDetails.payinFee} ${paymentDetails.payinCurrency}', + ), + if (quote != null) + _buildRow( + context, + Loc.of(context).total, + '${calculateTotalAmount(quote)} ${paymentDetails.payinCurrency}', + isBold: true, + ), + ], + ), + ); + } + + static double calculateExchangeRate(QuoteData? quote) => + double.parse(quote?.payout.amount ?? '0') / + double.parse(quote?.payin.amount ?? '0'); + + static double calculateTotalFees(QuoteData? quote) => + double.parse(quote?.payin.fee ?? '0') + + double.parse(quote?.payout.fee ?? '0') / calculateExchangeRate(quote); + + static String calculateTotalAmount(QuoteData? quote) => + CurrencyUtil.formatFromDouble( + double.parse(quote?.payin.amount ?? '0') + calculateTotalFees(quote), + currency: quote?.payin.currencyCode ?? '', + ); + + Widget _buildRow( + BuildContext context, + String title, + String value, { + bool isBold = false, + }) => + Column( + children: [ Row( children: [ Expanded( child: Text( - Loc.of(context).serviceFee, + title, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface, + fontWeight: + isBold ? FontWeight.bold : FontWeight.normal, ), ), ), Expanded( flex: 2, child: Text( - '$serviceFee $payoutCurrency', + value, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface, + fontWeight: + isBold ? FontWeight.bold : FontWeight.normal, ), textAlign: TextAlign.right, ), ), ], ), - if (total.isNotEmpty) - Column( - children: [ - const SizedBox(height: Grid.xs), - Row( - children: [ - Expanded( - child: Text( - Loc.of(context).total, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - Expanded( - flex: 2, - child: Text( - '$total $payoutCurrency', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - textAlign: TextAlign.right, - ), - ), - ], - ), - ], - ), + const SizedBox(height: Grid.xs), ], - ), - ); - } + ); +} + +extension _PaymentDetailsOperations on Object? { + String? get payinCurrency => this is QuoteData + ? (this as QuoteData?)?.payin.currencyCode + : this is OfferingData + ? (this as OfferingData?)?.payin.currencyCode + : null; + + String? get payoutCurrency => this is QuoteData + ? (this as QuoteData?)?.payout.currencyCode + : this is OfferingData + ? (this as OfferingData?)?.payout.currencyCode + : null; + + String? get exchangeRate => this is QuoteData + ? CurrencyUtil.formatFromDouble( + FeeDetails.calculateExchangeRate(this as QuoteData?), + ) + : this is OfferingData + ? (this as OfferingData?)?.payoutUnitsPerPayinUnit + : null; + + String? get payinFee => this is QuoteData + ? (this as QuoteData?)?.payin.fee + : this is OfferingData + ? (this as OfferingData?)?.payin.methods.firstOrNull?.fee ?? '0' + : null; + + String? get payoutFee => this is QuoteData + ? (this as QuoteData?)?.payout.fee + : this is OfferingData + ? (this as OfferingData?)?.payout.methods.firstOrNull?.fee ?? '0' + : null; } diff --git a/test/features/payin/payin_test.dart b/test/features/payin/payin_test.dart index b06da412..c11b7d95 100644 --- a/test/features/payin/payin_test.dart +++ b/test/features/payin/payin_test.dart @@ -72,7 +72,7 @@ void main() { expect(find.textContaining('AUD'), findsOneWidget); }); - testWidgets('should show deposit label', (tester) async { + testWidgets('should show pay label', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget( child: Payin( @@ -85,7 +85,7 @@ void main() { ), ); - expect(find.text('You deposit'), findsOneWidget); + expect(find.text('You pay'), findsOneWidget); }); testWidgets('should show withdraw label', (tester) async { diff --git a/test/features/payment/review_payment_page_test.dart b/test/features/payment/review_payment_page_test.dart index b5fd27c4..329dd654 100644 --- a/test/features/payment/review_payment_page_test.dart +++ b/test/features/payment/review_payment_page_test.dart @@ -69,7 +69,7 @@ void main() async { await tester.pumpAndSettle(); expect(find.byType(FeeDetails), findsOneWidget); - expect(find.text('0.50 MXN'), findsOneWidget); + expect(find.text('0.5 MXN'), findsOneWidget); }); testWidgets('should show bank name', (tester) async { @@ -81,14 +81,14 @@ void main() async { expect(find.text('ABC Bank'), findsOneWidget); }); - testWidgets('should show request confirmation page on tap of submit button', + testWidgets('should show payment confirmation page on tap of submit button', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget(child: reviewPaymentPageTestWidget()), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Submit')); + await tester.tap(find.text('Pay 10.11 USD')); await tester.pumpAndSettle(); expect(find.byType(PaymentConfirmationPage), findsOneWidget); diff --git a/test/features/payout/payout_test.dart b/test/features/payout/payout_test.dart index 32252bd0..5286a5bf 100644 --- a/test/features/payout/payout_test.dart +++ b/test/features/payout/payout_test.dart @@ -76,7 +76,7 @@ void main() { child: Payout( payoutAmount: amount, selectedOffering: offering, - transactionType: TransactionType.deposit, + transactionType: TransactionType.withdraw, payinAmount: 0, offerings: const [], ), diff --git a/test/shared/fee_details_test.dart b/test/shared/fee_details_test.dart index f8b62c2b..293112e2 100644 --- a/test/shared/fee_details_test.dart +++ b/test/shared/fee_details_test.dart @@ -1,70 +1,83 @@ +import 'dart:convert'; + +import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/shared/fee_details.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tbdex/tbdex.dart'; import '../helpers/widget_helpers.dart'; void main() { + const jsonString = + r'''{"metadata":{"kind":"offering","from":"did:web:localhost%3A8892:ingress","id":"offering_01hv22zfv1eptadkm92v278gh9","protocol":"1.0","createdAt":"2024-04-12T20:57:11Z","updatedAt":"2024-04-12T20:57:11Z"},"data":{"description":"MXN for USD","payoutUnitsPerPayinUnit":"16.34","payin":{"currencyCode":"USD","methods":[{"kind":"STORED_BALANCE","name":"Account balance"}]},"payout":{"currencyCode":"MXN","methods":[{"kind":"SPEI","estimatedSettlementTime":300,"name":"SPEI","requiredPaymentDetails":{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":false,"properties":{"clabe":{"type":"string"},"fullName":{"type":"string"}},"required":["clabe","fullName"]}}]}},"signature":"eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDp3ZWI6bG9jYWxob3N0JTNBODg5MjppbmdyZXNzIzAifQ..le65W3WyI2UKMJojADv_lTQixt0wDmnMMBVaWC_2BaYVQfe8HY3gQyPqbI4dT-iDNRjg_EdlCvTiEzANfp0lDw"}'''; + + final json = jsonDecode(jsonString); + final offering = Offering.fromJson(json); + group('FeeDetails', () { - testWidgets('should show input text strings', (tester) async { + testWidgets('should show est rate label', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget( - child: const FeeDetails( - payinCurrency: 'USD', - payoutCurrency: 'MXN', - exchangeRate: '17', - serviceFee: '0', + child: FeeDetails( + transactionType: TransactionType.deposit, + offering: offering.data, ), ), ); - expect(find.text('1 USD = 17 MXN'), findsOneWidget); - expect(find.text('0 MXN'), findsOneWidget); + expect(find.text('Est. rate'), findsOneWidget); }); - testWidgets('should show est rate', (tester) async { + testWidgets('should show deposit fee label', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget( - child: const FeeDetails( - payinCurrency: 'USD', - payoutCurrency: 'MXN', - exchangeRate: '17', - serviceFee: '0', + child: FeeDetails( + transactionType: TransactionType.deposit, + offering: offering.data, ), ), ); - expect(find.text('Est. rate'), findsOneWidget); + expect(find.text('Deposit fee'), findsOneWidget); }); - testWidgets('should show exchange rate', (tester) async { + testWidgets('should show withdraw fee label', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget( - child: const FeeDetails( - payinCurrency: 'USD', - payoutCurrency: 'MXN', - exchangeRate: '17', - serviceFee: '0', + child: FeeDetails( + transactionType: TransactionType.withdraw, + offering: offering.data, ), ), ); - final exchangeRatePattern = RegExp(r'1 [A-Z]{3} = \d+ [A-Z]{3}'); - expect(find.textContaining(exchangeRatePattern), findsOneWidget); + expect(find.text('Withdraw fee'), findsOneWidget); }); - testWidgets('should show service fee', (tester) async { + testWidgets('should show send fee label', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testableWidget( + child: FeeDetails( + transactionType: TransactionType.send, + offering: offering.data, + ), + ), + ); + + expect(find.text('Send fee'), findsOneWidget); + }); + + testWidgets('should show est rate', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget( - child: const FeeDetails( - payinCurrency: 'USD', - payoutCurrency: 'MXN', - exchangeRate: '17', - serviceFee: '0', + child: FeeDetails( + transactionType: TransactionType.deposit, + offering: offering.data, ), ), ); - expect(find.text('Service fee'), findsOneWidget); + expect(find.textContaining('1 USD = 16.34 MXN'), findsOneWidget); }); }); }