diff --git a/lib/features/payin/deposit_page.dart b/lib/features/payin/deposit_page.dart index 4173ff68..7bd9c1e1 100644 --- a/lib/features/payin/deposit_page.dart +++ b/lib/features/payin/deposit_page.dart @@ -1,5 +1,3 @@ -import 'package:didpay/config/config.dart'; -import 'package:didpay/features/account/account_providers.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payin/payin.dart'; import 'package:didpay/features/payin/payin_details_page.dart'; @@ -24,11 +22,8 @@ class DepositPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final country = ref.read(countryProvider); - final pfi = Config.getPfi(country); - // TODO(ethan-tbd): filter offerings with STORED_BALANCE as payout, https://github.com/TBD54566975/didpay/issues/132 - final offerings = ref.watch(offeringsProvider(pfi?.didUri ?? '')); + final offerings = ref.watch(offeringsProvider); final payinAmount = useState('0'); final payoutAmount = useState(0); @@ -137,7 +132,7 @@ class DepositPage extends HookConsumerWidget { builder: (context) => PayinDetailsPage( rfqState: rfqState.copyWith( payinAmount: payinAmount, - offeringId: selectedOffering?.metadata.id ?? '', + offering: selectedOffering, payinMethod: selectedOffering?.data.payin.methods.firstOrNull, payoutMethod: selectedOffering?.data.payout.methods.firstOrNull, ), diff --git a/lib/features/payin/payin_details_page.dart b/lib/features/payin/payin_details_page.dart index bd0efc30..1ab32e65 100644 --- a/lib/features/payin/payin_details_page.dart +++ b/lib/features/payin/payin_details_page.dart @@ -1,13 +1,11 @@ import 'package:collection/collection.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payin/search_payin_methods_page.dart'; +import 'package:didpay/features/payment/payment_details.dart'; import 'package:didpay/features/payment/payment_state.dart'; -import 'package:didpay/features/payment/review_payment_page.dart'; import 'package:didpay/features/payment/search_payment_types_page.dart'; import 'package:didpay/features/tbdex/rfq_state.dart'; -import 'package:didpay/features/tbdex/tbdex_providers.dart'; import 'package:didpay/l10n/app_localizations.dart'; -import 'package:didpay/shared/json_schema_form.dart'; import 'package:didpay/shared/theme/grid.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -81,7 +79,12 @@ class PayinDetailsPage extends HookConsumerWidget { selectedPayinMethod, filteredPayinMethods, ), - _buildForm(context, ref, selectedPayinMethod.value), + PaymentDetails.buildForm( + context, + ref, + rfqState.copyWith(payinMethod: selectedPayinMethod.value), + paymentState, + ), ], ), ), @@ -203,55 +206,4 @@ class PayinDetailsPage extends HookConsumerWidget { ], ); } - - Widget _buildForm( - BuildContext context, - WidgetRef ref, - PayinMethod? selectedPayinMethod, - ) => - selectedPayinMethod == null - ? _buildDisabledButton(context) - : Expanded( - child: JsonSchemaForm( - schema: selectedPayinMethod.requiredPaymentDetails?.toJson(), - onSubmit: (formData) { - // TODO(ethan-tbd): wait for quote to come back before navigating, https://github.com/TBD54566975/didpay/issues/131 - ref.read( - rfqProvider( - rfqState.copyWith(payinMethod: selectedPayinMethod), - ), - ); - - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ReviewPaymentPage( - rfqState: rfqState, - paymentState: paymentState.copyWith( - serviceFee: selectedPayinMethod.fee, - paymentName: selectedPayinMethod.name ?? - selectedPayinMethod.kind, - formData: formData, - ), - ), - ), - ); - }, - ), - ); - - Widget _buildDisabledButton(BuildContext context) => Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: Container()), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.side), - child: FilledButton( - onPressed: null, - child: Text(Loc.of(context).next), - ), - ), - ], - ), - ); } diff --git a/lib/features/payment/payment_details.dart b/lib/features/payment/payment_details.dart new file mode 100644 index 00000000..65e9d1e2 --- /dev/null +++ b/lib/features/payment/payment_details.dart @@ -0,0 +1,90 @@ +import 'package:didpay/features/home/transaction.dart'; +import 'package:didpay/features/payment/payment_state.dart'; +import 'package:didpay/features/payment/review_payment_page.dart'; +import 'package:didpay/features/tbdex/rfq_state.dart'; +import 'package:didpay/features/tbdex/tbdex.dart'; +import 'package:didpay/l10n/app_localizations.dart'; +import 'package:didpay/shared/json_schema_form.dart'; +import 'package:didpay/shared/theme/grid.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:tbdex/tbdex.dart'; + +class PaymentDetails { + static Widget buildForm( + BuildContext context, + WidgetRef ref, + RfqState rfqState, + PaymentState paymentState, + ) { + final paymentMethod = + paymentState.transactionType == TransactionType.deposit + ? rfqState.payinMethod + : rfqState.payoutMethod; + + final isDisabled = paymentMethod.isDisabled; + final schema = paymentMethod.schema; + final fee = paymentMethod.serviceFee; + final paymentName = paymentMethod.paymentName; + + return isDisabled + ? _buildDisabledButton(context) + : Expanded( + child: JsonSchemaForm( + schema: schema, + onSubmit: (formData) => + // TODO(mistermoe): check requiredClaims and navigate to kcc flow if needed, https://github.com/TBD54566975/didpay/issues/122 + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ReviewPaymentPage( + rfq: Tbdex.createRfq(ref, rfqState), + paymentState: paymentState.copyWith( + serviceFee: fee, + paymentName: paymentName, + formData: formData, + ), + ), + ), + ), + ), + ); + } + + static Widget _buildDisabledButton(BuildContext context) => Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: Container()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.side), + child: FilledButton( + onPressed: null, + child: Text(Loc.of(context).next), + ), + ), + ], + ), + ); +} + +extension _PaymentMethodOperations on Object? { + bool get isDisabled => this == null; + + String? get schema => this is PayinMethod + ? (this as PayinMethod?)?.requiredPaymentDetails?.toJson() + : this is PayoutMethod + ? (this as PayoutMethod?)?.requiredPaymentDetails?.toJson() + : null; + + String? get serviceFee => this is PayinMethod + ? (this as PayinMethod?)?.fee + : this is PayoutMethod + ? (this as PayoutMethod?)?.fee + : null; + + String? get paymentName => this is PayinMethod + ? (this as PayinMethod?)?.name ?? (this as PayinMethod?)?.kind + : this is PayoutMethod + ? (this as PayoutMethod?)?.name ?? (this as PayoutMethod?)?.kind + : null; +} diff --git a/lib/features/payment/review_payment_page.dart b/lib/features/payment/review_payment_page.dart index f23aa1c5..14e88d7d 100644 --- a/lib/features/payment/review_payment_page.dart +++ b/lib/features/payment/review_payment_page.dart @@ -2,58 +2,87 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payment/payment_confirmation_page.dart'; import 'package:didpay/features/payment/payment_state.dart'; -import 'package:didpay/features/tbdex/rfq_state.dart'; +import 'package:didpay/features/tbdex/quote_notifier.dart'; +import 'package:didpay/features/tbdex/tbdex_providers.dart'; import 'package:didpay/l10n/app_localizations.dart'; import 'package:didpay/shared/fee_details.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:hooks_riverpod/hooks_riverpod.dart'; +import 'package:tbdex/tbdex.dart'; -class ReviewPaymentPage extends HookWidget { - // TODO(ethan-tbd): replace with quote, https://github.com/TBD54566975/didpay/issues/131 - final RfqState rfqState; +class ReviewPaymentPage extends HookConsumerWidget { + final Rfq rfq; final PaymentState paymentState; const ReviewPaymentPage({ - required this.rfqState, + required this.rfq, required this.paymentState, super.key, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + QuoteAsyncNotifier getQuoteNotifier() => ref.read(quoteProvider.notifier); + final quoteResult = ref.watch(quoteProvider); + + useEffect( + () { + Future.delayed(Duration.zero, () { + ref.read(rfqProvider(rfq)); + getQuoteNotifier().startPolling(rfq.metadata.id); + }); + return getQuoteNotifier().stopPolling; + }, + [], + ); + return Scaffold( appBar: AppBar(), body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.side), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(context), - Expanded( - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: quoteResult.when( + data: (quote) => quote != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: Grid.sm), - _buildAmounts(context), - _buildFeeDetails(context), - _buildPaymentDetails(context), + _buildHeader(context), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: Grid.sm), + _buildAmounts(context, quote.data), + _buildFeeDetails(context, quote.data), + _buildPaymentDetails(context), + ], + ), + ), + ), + _buildSubmitButton(context), ], - ), - ), + ) + : _loading(), + loading: _loading, + error: (error, stackTrace) => Center( + child: Text( + 'Failed to get quote: $error', + style: Theme.of(context).textTheme.displaySmall, ), - _buildSubmitButton(context), - ], + ), ), ), ), ); } + Widget _loading() => const Center(child: CircularProgressIndicator()); + Widget _buildHeader(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: Grid.xs), child: Column( @@ -79,7 +108,7 @@ class ReviewPaymentPage extends HookWidget { ), ); - Widget _buildAmounts(BuildContext context) => Column( + Widget _buildAmounts(BuildContext context, QuoteData quote) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( @@ -89,8 +118,8 @@ class ReviewPaymentPage extends HookWidget { Flexible( child: AutoSizeText( CurrencyUtil.formatFromString( - rfqState.payinAmount ?? '0', - currency: paymentState.payinCurrency.toUpperCase(), + quote.payin.amount, + currency: quote.payin.currencyCode.toUpperCase(), ), style: Theme.of(context).textTheme.headlineMedium, maxLines: 1, @@ -100,7 +129,7 @@ class ReviewPaymentPage extends HookWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Text( - paymentState.payinCurrency, + quote.payin.currencyCode, style: Theme.of(context).textTheme.headlineSmall, ), ), @@ -115,14 +144,14 @@ class ReviewPaymentPage extends HookWidget { children: [ Flexible( child: AutoSizeText( - paymentState.payoutAmount, + quote.payout.amount, style: Theme.of(context).textTheme.headlineMedium, maxLines: 1, ), ), const SizedBox(width: Grid.xs), Text( - paymentState.payoutCurrency, + quote.payout.currencyCode, style: Theme.of(context).textTheme.headlineSmall, ), ], @@ -160,24 +189,24 @@ class ReviewPaymentPage extends HookWidget { return Text(label, style: style); } - Widget _buildFeeDetails(BuildContext context) => Padding( + // 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: paymentState.payinCurrency != Loc.of(context).usd - ? paymentState.payinCurrency - : paymentState.payoutCurrency, + payoutCurrency: quote.payin.currencyCode != Loc.of(context).usd + ? quote.payin.currencyCode + : quote.payout.currencyCode, exchangeRate: paymentState.exchangeRate, - serviceFee: - double.parse(paymentState.serviceFee ?? '0').toStringAsFixed(2), - total: paymentState.payinCurrency != Loc.of(context).usd + serviceFee: double.parse(quote.payout.fee ?? '0').toStringAsFixed(2), + total: paymentState.transactionType == TransactionType.deposit ? (double.parse( - (rfqState.payinAmount ?? '0').replaceAll(',', ''), - ) + - double.parse(paymentState.serviceFee ?? '0')) + quote.payin.amount.replaceAll(',', ''), + ) - + double.parse(quote.payin.fee ?? '0')) .toStringAsFixed(2) - : (double.parse(paymentState.payoutAmount.replaceAll(',', '')) + - double.parse(paymentState.serviceFee ?? '0')) + : (double.parse(quote.payout.amount.replaceAll(',', '')) - + double.parse(quote.payout.fee ?? '0')) .toStringAsFixed(2), ), ); diff --git a/lib/features/payout/payout_details_page.dart b/lib/features/payout/payout_details_page.dart index 6cd5ca2f..18f767a6 100644 --- a/lib/features/payout/payout_details_page.dart +++ b/lib/features/payout/payout_details_page.dart @@ -1,13 +1,11 @@ import 'package:collection/collection.dart'; import 'package:didpay/features/home/transaction.dart'; +import 'package:didpay/features/payment/payment_details.dart'; import 'package:didpay/features/payment/payment_state.dart'; -import 'package:didpay/features/payment/review_payment_page.dart'; import 'package:didpay/features/payment/search_payment_types_page.dart'; import 'package:didpay/features/payout/search_payout_methods_page.dart'; import 'package:didpay/features/tbdex/rfq_state.dart'; -import 'package:didpay/features/tbdex/tbdex_providers.dart'; import 'package:didpay/l10n/app_localizations.dart'; -import 'package:didpay/shared/json_schema_form.dart'; import 'package:didpay/shared/theme/grid.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -81,7 +79,12 @@ class PayoutDetailsPage extends HookConsumerWidget { selectedPayoutMethod, filteredPayoutMethods, ), - _buildForm(context, ref, selectedPayoutMethod.value), + PaymentDetails.buildForm( + context, + ref, + rfqState.copyWith(payoutMethod: selectedPayoutMethod.value), + paymentState, + ), ], ), ), @@ -203,55 +206,4 @@ class PayoutDetailsPage extends HookConsumerWidget { ], ); } - - Widget _buildForm( - BuildContext context, - WidgetRef ref, - PayoutMethod? selectedPayoutMethod, - ) => - selectedPayoutMethod == null - ? _buildDisabledButton(context) - : Expanded( - child: JsonSchemaForm( - schema: selectedPayoutMethod.requiredPaymentDetails?.toJson(), - onSubmit: (formData) { - // TODO(ethan-tbd): wait for quote to come back before navigating, https://github.com/TBD54566975/didpay/issues/131 - ref.read( - rfqProvider( - rfqState.copyWith(payoutMethod: selectedPayoutMethod), - ), - ); - - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ReviewPaymentPage( - rfqState: rfqState, - paymentState: paymentState.copyWith( - serviceFee: selectedPayoutMethod.fee, - paymentName: selectedPayoutMethod.name ?? - selectedPayoutMethod.kind, - formData: formData, - ), - ), - ), - ); - }, - ), - ); - - Widget _buildDisabledButton(BuildContext context) => Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: Container()), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.side), - child: FilledButton( - onPressed: null, - child: Text(Loc.of(context).next), - ), - ), - ], - ), - ); } diff --git a/lib/features/payout/withdraw_page.dart b/lib/features/payout/withdraw_page.dart index 57abe76e..63fabfc8 100644 --- a/lib/features/payout/withdraw_page.dart +++ b/lib/features/payout/withdraw_page.dart @@ -1,5 +1,3 @@ -import 'package:didpay/config/config.dart'; -import 'package:didpay/features/account/account_providers.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payin/payin.dart'; import 'package:didpay/features/payment/payment_state.dart'; @@ -24,11 +22,8 @@ class WithdrawPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final country = ref.read(countryProvider); - final pfi = Config.getPfi(country); - // TODO(ethan-tbd): filter offerings with STORED_BALANCE as payin, https://github.com/TBD54566975/didpay/issues/132 - final offerings = ref.watch(offeringsProvider(pfi?.didUri ?? '')); + final offerings = ref.watch(offeringsProvider); final payinAmount = useState('0'); final payoutAmount = useState(0); @@ -135,7 +130,7 @@ class WithdrawPage extends HookConsumerWidget { builder: (context) => PayoutDetailsPage( rfqState: rfqState.copyWith( payinAmount: payinAmount, - offeringId: selectedOffering?.metadata.id ?? '', + offering: selectedOffering, payinMethod: selectedOffering?.data.payin.methods.firstOrNull, payoutMethod: selectedOffering?.data.payout.methods.firstOrNull, ), @@ -146,7 +141,7 @@ class WithdrawPage extends HookConsumerWidget { selectedOffering?.data.payout.currencyCode ?? '', exchangeRate: selectedOffering?.data.payoutUnitsPerPayinUnit ?? '', - transactionType: TransactionType.deposit, + transactionType: TransactionType.withdraw, payoutMethods: selectedOffering?.data.payout.methods ?? [], ), ), diff --git a/lib/features/remittance/remittance_page.dart b/lib/features/remittance/remittance_page.dart index 1a3ed906..be826199 100644 --- a/lib/features/remittance/remittance_page.dart +++ b/lib/features/remittance/remittance_page.dart @@ -1,4 +1,3 @@ -import 'package:didpay/config/config.dart'; import 'package:didpay/features/countries/country.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payin/payin.dart'; @@ -29,9 +28,8 @@ class RemittancePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final pfi = Config.getPfi(country); // TODO(ethan-tbd): use country to filter offerings, https://github.com/TBD54566975/didpay/issues/134 - final offerings = ref.watch(offeringsProvider(pfi?.didUri ?? '')); + final offerings = ref.watch(offeringsProvider); final payinAmount = useState('0'); final payoutAmount = useState(0); @@ -138,7 +136,7 @@ class RemittancePage extends HookConsumerWidget { builder: (context) => PayoutDetailsPage( rfqState: rfqState.copyWith( payinAmount: payinAmount, - offeringId: selectedOffering?.metadata.id ?? '', + offering: selectedOffering, payinMethod: selectedOffering?.data.payin.methods.firstOrNull, payoutMethod: selectedOffering?.data.payout.methods.firstOrNull, ), @@ -149,7 +147,7 @@ class RemittancePage extends HookConsumerWidget { selectedOffering?.data.payout.currencyCode ?? '', exchangeRate: selectedOffering?.data.payoutUnitsPerPayinUnit ?? '', - transactionType: TransactionType.deposit, + transactionType: TransactionType.send, payoutMethods: selectedOffering?.data.payout.methods ?? [], ), ), diff --git a/lib/features/tbdex/quote_notifier.dart b/lib/features/tbdex/quote_notifier.dart new file mode 100644 index 00000000..456a96ee --- /dev/null +++ b/lib/features/tbdex/quote_notifier.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:didpay/config/config.dart'; +import 'package:didpay/features/account/account_providers.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:tbdex/tbdex.dart'; + +class QuoteAsyncNotifier extends AutoDisposeAsyncNotifier { + static const _maxCallsPerInterval = 10; + static const _maxPollingDuration = Duration(minutes: 2); + + static final List _backoffIntervals = [ + const Duration(seconds: 1), + const Duration(seconds: 5), + const Duration(seconds: 10), + const Duration(seconds: 20), + ]; + + int _numCalls = 0; + Timer? _timer; + DateTime? _pollingStart; + Duration _currentInterval = _backoffIntervals.first; + + @override + FutureOr build() => null; + + void startPolling(String exchangeId) { + _timer?.cancel(); + _pollingStart ??= DateTime.now(); + + if (DateTime.now().difference(_pollingStart!) > _maxPollingDuration) { + state = AsyncValue.error( + Exception('forced timeout after 2 minutes'), + StackTrace.current, + ); + stopPolling(); + return; + } + state = const AsyncValue.loading(); + + _timer = Timer.periodic(_currentInterval, (_) async { + try { + final exchange = await _fetchExchange(exchangeId); + if (_containsQuote(exchange)) { + state = AsyncValue.data(_getQuote(exchange)); + stopPolling(); + } else { + _increaseBackoff(exchangeId); + } + } on Exception catch (e) { + state = AsyncValue.error( + Exception('Failed to fetch exchange: $e'), + StackTrace.current, + ); + stopPolling(); + } + }); + } + + void _increaseBackoff(String exchangeId) { + _currentInterval = _backoffIntervals[ + min(_numCalls ~/ _maxCallsPerInterval, _backoffIntervals.length - 1)]; + _numCalls++; + + startPolling(exchangeId); + } + + void stopPolling() { + _timer?.cancel(); + _timer = null; + _pollingStart = null; + _numCalls = 0; + _currentInterval = _backoffIntervals.first; + } + + Future _fetchExchange(String exchangeId) async { + final did = ref.read(didProvider); + final country = ref.read(countryProvider); + final pfi = Config.getPfi(country); + + final exchange = await TbdexHttpClient.getExchange( + did, + pfi?.didUri ?? '', + exchangeId, + ); + + return exchange; + } + + bool _containsQuote(Exchange exchange) => + exchange.any((message) => message.metadata.kind == MessageKind.quote); + + Quote? _getQuote(Exchange exchange) => exchange.firstWhere( + (message) => message.metadata.kind == MessageKind.quote, + ) as Quote; +} diff --git a/lib/features/tbdex/rfq_state.dart b/lib/features/tbdex/rfq_state.dart index aabff533..2a7c54f8 100644 --- a/lib/features/tbdex/rfq_state.dart +++ b/lib/features/tbdex/rfq_state.dart @@ -2,26 +2,26 @@ import 'package:tbdex/tbdex.dart'; class RfqState { final String? payinAmount; - final String? offeringId; + final Offering? offering; final PayinMethod? payinMethod; final PayoutMethod? payoutMethod; const RfqState({ this.payinAmount, - this.offeringId, + this.offering, this.payinMethod, this.payoutMethod, }); RfqState copyWith({ String? payinAmount, - String? offeringId, + Offering? offering, PayinMethod? payinMethod, PayoutMethod? payoutMethod, }) { return RfqState( payinAmount: payinAmount ?? this.payinAmount, - offeringId: offeringId ?? this.offeringId, + offering: offering ?? this.offering, payinMethod: payinMethod ?? this.payinMethod, payoutMethod: payoutMethod ?? this.payoutMethod, ); @@ -29,6 +29,6 @@ class RfqState { @override String toString() { - return 'RfqState(payinAmount: $payinAmount, offeringId: $offeringId, payinMethod: $payinMethod, payoutMethod: $payoutMethod)'; + return 'RfqState(payinAmount: $payinAmount, offering: ${offering?.toJson()}, payinMethod: $payinMethod, payoutMethod: $payoutMethod)'; } } diff --git a/lib/features/tbdex/tbdex.dart b/lib/features/tbdex/tbdex.dart new file mode 100644 index 00000000..4f90dc38 --- /dev/null +++ b/lib/features/tbdex/tbdex.dart @@ -0,0 +1,31 @@ +import 'package:didpay/config/config.dart'; +import 'package:didpay/features/account/account_providers.dart'; +import 'package:didpay/features/tbdex/rfq_state.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:tbdex/tbdex.dart'; + +class Tbdex { + static Rfq createRfq(WidgetRef ref, RfqState rfqState) { + final did = ref.read(didProvider); + final country = ref.read(countryProvider); + final pfi = Config.getPfi(country); + + return Rfq.create( + pfi?.didUri ?? '', + did.uri, + CreateRfqData( + offeringId: rfqState.offering?.metadata.id ?? '', + payin: CreateSelectedPayinMethod( + amount: rfqState.payinAmount ?? '', + kind: rfqState.payinMethod?.kind ?? '', + ), + payout: CreateSelectedPayoutMethod( + kind: rfqState.payoutMethod?.kind ?? '', + ), + claims: [], + ), + ); + } + + // TODO(ethan-tbd): create order, https://github.com/TBD54566975/didpay/issues/115 +} diff --git a/lib/features/tbdex/tbdex_providers.dart b/lib/features/tbdex/tbdex_providers.dart index bda95b63..424da709 100644 --- a/lib/features/tbdex/tbdex_providers.dart +++ b/lib/features/tbdex/tbdex_providers.dart @@ -1,17 +1,15 @@ import 'package:didpay/config/config.dart'; import 'package:didpay/features/account/account_providers.dart'; -import 'package:didpay/features/tbdex/rfq_state.dart'; +import 'package:didpay/features/tbdex/quote_notifier.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:tbdex/tbdex.dart'; final offeringsProvider = - FutureProvider.family.autoDispose, String>((ref, did) async { + FutureProvider.autoDispose>((ref) async { try { final country = ref.read(countryProvider); final pfi = Config.getPfi(country); - final offerings = await TbdexHttpClient.getOfferings( - pfi?.didUri ?? '', - ); + final offerings = await TbdexHttpClient.getOfferings(pfi?.didUri ?? ''); return offerings; } on Exception catch (e) { throw Exception('Failed to load offerings: $e'); @@ -19,33 +17,18 @@ final offeringsProvider = }); final rfqProvider = - FutureProvider.family.autoDispose((ref, rfqState) async { + FutureProvider.family.autoDispose((ref, rfq) async { try { final did = ref.read(didProvider); - final country = ref.read(countryProvider); - final pfi = Config.getPfi(country); - - final rfq = Rfq.create( - pfi?.didUri ?? '', - did.uri, - CreateRfqData( - offeringId: rfqState.offeringId ?? '', - payin: CreateSelectedPayinMethod( - amount: rfqState.payinAmount ?? '', - kind: rfqState.payinMethod?.kind ?? '', - ), - payout: CreateSelectedPayoutMethod( - kind: rfqState.payoutMethod?.kind ?? '', - ), - claims: [], - ), - ); await rfq.sign(did); await TbdexHttpClient.createExchange(rfq, replyTo: rfq.metadata.from); } on Exception catch (e) { - throw Exception('Failed to load RFQ: $e'); + throw Exception('Failed to send rfq: $e'); } }); -// TODO(ethan-tbd): add providers for other tbdex client methods below +final quoteProvider = + AsyncNotifierProvider.autoDispose( + QuoteAsyncNotifier.new, +); diff --git a/test/features/payin/deposit_page_test.dart b/test/features/payin/deposit_page_test.dart index 1688d38e..b93a90e9 100644 --- a/test/features/payin/deposit_page_test.dart +++ b/test/features/payin/deposit_page_test.dart @@ -27,7 +27,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -44,7 +44,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -60,7 +60,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -77,7 +77,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -103,7 +103,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); diff --git a/test/features/payment/review_payment_page_test.dart b/test/features/payment/review_payment_page_test.dart index 0b8d0c09..b5fd27c4 100644 --- a/test/features/payment/review_payment_page_test.dart +++ b/test/features/payment/review_payment_page_test.dart @@ -1,63 +1,82 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:auto_size_text/auto_size_text.dart'; +import 'package:didpay/features/account/account_providers.dart'; +import 'package:didpay/features/countries/country.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payment/payment_confirmation_page.dart'; import 'package:didpay/features/payment/payment_state.dart'; import 'package:didpay/features/payment/review_payment_page.dart'; -import 'package:didpay/features/tbdex/rfq_state.dart'; +import 'package:didpay/features/tbdex/quote_notifier.dart'; +import 'package:didpay/features/tbdex/tbdex_providers.dart'; import 'package:didpay/shared/fee_details.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:tbdex/tbdex.dart'; +import 'package:web5/web5.dart'; import '../../helpers/widget_helpers.dart'; -void main() { +void main() async { + const rfqString = + '{"metadata":{"kind":"rfq","to":"did:web:localhost%3A8892:ingress","from":"did:jwk:eyJrdHkiOiJPS1AiLCJhbGciOiJFZERTQSIsImtpZCI6ImRGU3FyQzlwZTBKc3kzVk1wRWpUVjdQalJTYlRvTXROWDI5dUZrRVZ3YTAiLCJjcnYiOiJFZDI1NTE5IiwieCI6Im9NQ0JpUTdpaWtWdk13aFRkRllYdHJSSFJaTmpmeDNyN1N0TlBQbVF0ak0ifQ","id":"rfq_01hwxpmsrje6cts27kdzy3n66y","exchangeId":"rfq_01hwxpmsrje6cts27kdzy3n66y","createdAt":"2024-05-02T22:26:20.060100Z","protocol":"1.0"},"data":{"offeringId":"offering_01hv22zfv1eptadkm92v278gh9","payin":{"amount":"22","kind":"STORED_BALANCE"},"payout":{"kind":"SPEI"},"claimsHash":"MyMUVjLdERoAqNHo0C_tGr1_QSN_jSZ_sM6U_X8rIgM"},"privateData":{"salt":"qZ8FwUP6Mz5BPYmyR59HoA","claims":[]},"signature":"eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDpqd2s6ZXlKcmRIa2lPaUpQUzFBaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJbVJHVTNGeVF6bHdaVEJLYzNrelZrMXdSV3BVVmpkUWFsSlRZbFJ2VFhST1dESTVkVVpyUlZaM1lUQWlMQ0pqY25ZaU9pSkZaREkxTlRFNUlpd2llQ0k2SW05TlEwSnBVVGRwYVd0V2RrMTNhRlJrUmxsWWRISlNTRkphVG1wbWVETnlOMU4wVGxCUWJWRjBhazBpZlEjMCJ9..AwP6XJf1IYeMVgKg7xryN61o_ZT-E9JBMhjYWxDCIC_y9uUhQvAgvKa6FQkdiINtma0NulMsQyCS6NyhAdqSDA"}'; + final rfqJson = jsonDecode(rfqString); + final rfq = Rfq.fromJson(rfqJson); + + final did = await DidJwk.create(); + Widget reviewPaymentPageTestWidget({List overrides = const []}) => WidgetHelpers.testableWidget( - child: const ReviewPaymentPage( - rfqState: RfqState( - payinAmount: '1.00', - ), - paymentState: PaymentState( + child: ReviewPaymentPage( + rfq: rfq, + paymentState: const PaymentState( payoutAmount: '17.00', payinCurrency: 'USD', payoutCurrency: 'MXN', exchangeRate: '17', transactionType: TransactionType.deposit, - serviceFee: '9.0', paymentName: 'ABC Bank', formData: {'accountNumber': '1234567890'}, ), ), - overrides: overrides, + overrides: [ + quoteProvider.overrideWith(_MockQuoteNotifier.new), + didProvider.overrideWithValue(did), + countryProvider.overrideWith( + (ref) => const Country(name: 'Mexico', code: 'MX'), + ), + ], ); group('ReviewPaymentPage', () { testWidgets('should show input and output amounts', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget(child: reviewPaymentPageTestWidget()), ); + await tester.pumpAndSettle(); - expect(find.widgetWithText(AutoSizeText, '1.00'), findsOneWidget); + expect(find.widgetWithText(AutoSizeText, '10'), findsOneWidget); expect(find.text('USD'), findsOneWidget); - expect(find.widgetWithText(AutoSizeText, '17.00'), findsOneWidget); + expect(find.widgetWithText(AutoSizeText, '500'), findsOneWidget); expect(find.text('MXN'), findsOneWidget); }); - testWidgets('should show fee table with service fee and total', - (tester) async { + testWidgets('should show fee details', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget(child: reviewPaymentPageTestWidget()), ); + await tester.pumpAndSettle(); expect(find.byType(FeeDetails), findsOneWidget); - expect(find.text('9.00 MXN'), findsOneWidget); - expect(find.text('26.00 MXN'), findsOneWidget); + expect(find.text('0.50 MXN'), findsOneWidget); }); testWidgets('should show bank name', (tester) async { await tester.pumpWidget( WidgetHelpers.testableWidget(child: reviewPaymentPageTestWidget()), ); + await tester.pumpAndSettle(); expect(find.text('ABC Bank'), findsOneWidget); }); @@ -67,6 +86,7 @@ void main() { await tester.pumpWidget( WidgetHelpers.testableWidget(child: reviewPaymentPageTestWidget()), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Submit')); await tester.pumpAndSettle(); @@ -75,3 +95,19 @@ void main() { }); }); } + +class _MockQuoteNotifier extends QuoteAsyncNotifier { + _MockQuoteNotifier() : super(); + + @override + FutureOr build() { + const quoteString = + '{"data":{"payin":{"fee":"0.1","amount":"10","currencyCode":"USD"},"payout":{"fee":"0.5","amount":"500","currencyCode":"MXN"},"expiresAt":"2024-05-03T22:26:39Z"},"metadata":{"id":"quote_01hwxpncmpfkmt16azhe9vhxvr","to":"did:jwk:eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6Ik5qUUNMeE9tVEN4NFlYQ1MyR2t0T2FQbkZHLXBUZFRqZ0F0U3AtX002SEEifQ","from":"did:jwk:eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6ImswMEpaTXFVdGRtUFZORkVhOHIxek1FWXZ3WXFCVnVVcWtLS3BsdkxhSEkifQ","kind":"quote","protocol":"1.0","createdAt":"2024-05-02T22:26:39Z","exchangeId":"rfq_01hwxpmsrje6cts27kdzy3n66y"},"signature":"eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDpqd2s6ZXlKcmRIa2lPaUpQUzFBaUxDSmpjbllpT2lKRlpESTFOVEU1SWl3aWVDSTZJbXN3TUVwYVRYRlZkR1J0VUZaT1JrVmhPSEl4ZWsxRldYWjNXWEZDVm5WVmNXdExTM0JzZGt4aFNFa2lmUSMwIn0..KRs_rAGNTZ_w9H-8lg3RR4jShi4iPTz4sW9o7eCmaMKPH4ETYbK4n0xRskKzyS-Wkx3oqGY0vFQs5kYsysi_Aw"}'; + final quoteJson = jsonDecode(quoteString); + final quote = Quote.fromJson(quoteJson); + return quote; + } + + @override + void startPolling(String exchangeId) {} +} diff --git a/test/features/payout/payout_details_page_test.dart b/test/features/payout/payout_details_page_test.dart index d8c679a6..ec522a9b 100644 --- a/test/features/payout/payout_details_page_test.dart +++ b/test/features/payout/payout_details_page_test.dart @@ -61,7 +61,7 @@ void main() { payinCurrency: 'USD', payoutCurrency: 'MXN', exchangeRate: '17', - transactionType: TransactionType.deposit, + transactionType: TransactionType.withdraw, payoutMethods: payoutMethods, ), ), diff --git a/test/features/payout/withdraw_page_test.dart b/test/features/payout/withdraw_page_test.dart index 9d57fb69..9d45a332 100644 --- a/test/features/payout/withdraw_page_test.dart +++ b/test/features/payout/withdraw_page_test.dart @@ -27,7 +27,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -44,7 +44,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -60,7 +60,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -77,7 +77,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -103,7 +103,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); diff --git a/test/features/remittance/remittance_page_test.dart b/test/features/remittance/remittance_page_test.dart index e025db98..83deeb5a 100644 --- a/test/features/remittance/remittance_page_test.dart +++ b/test/features/remittance/remittance_page_test.dart @@ -30,7 +30,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -48,7 +48,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -65,7 +65,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), ); @@ -83,7 +83,7 @@ void main() { rfqState: RfqState(), ), overrides: [ - offeringsProvider.overrideWith((ref, did) async => offerings), + offeringsProvider.overrideWith((ref) async => offerings), ], ), );