From a5fa32f892adb6bed46591c0994d2f4d3a475da1 Mon Sep 17 00:00:00 2001 From: Ethan Lee <125412902+ethan-tbd@users.noreply.github.com> Date: Fri, 10 May 2024 14:03:46 -0700 Subject: [PATCH] feat: `TbdexHttpClient` improvements (#148) --- lib/features/payment/payment_details.dart | 53 +++++++------- lib/features/payment/review_payment_page.dart | 64 +++++++++++------ lib/features/tbdex/quote_notifier.dart | 12 +++- lib/features/tbdex/tbdex_exceptions.dart | 26 +++++++ lib/features/tbdex/tbdex_providers.dart | 34 +++++---- lib/l10n/app_en.arb | 4 +- lib/l10n/app_localizations.dart | 12 ++++ lib/l10n/app_localizations_en.dart | 6 ++ lib/shared/http_status.dart | 29 ++++++++ lib/shared/json_schema_form.dart | 72 +++++++++++++------ pubspec.lock | 2 +- .../payin/payin_details_page_test.dart | 9 ++- .../payment/review_payment_page_test.dart | 11 +-- .../payout/payout_details_page_test.dart | 9 ++- 14 files changed, 244 insertions(+), 99 deletions(-) create mode 100644 lib/features/tbdex/tbdex_exceptions.dart create mode 100644 lib/shared/http_status.dart diff --git a/lib/features/payment/payment_details.dart b/lib/features/payment/payment_details.dart index 65e9d1e2..2bb36f1c 100644 --- a/lib/features/payment/payment_details.dart +++ b/lib/features/payment/payment_details.dart @@ -3,10 +3,10 @@ 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/features/tbdex/tbdex_providers.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'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:tbdex/tbdex.dart'; @@ -27,17 +27,23 @@ class PaymentDetails { 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( + final rfq = Tbdex.createRfq(ref, rfqState); + final isLoading = useState(false); + + return Expanded( + child: JsonSchemaForm( + schema: schema, + isDisabled: isDisabled, + isLoading: isLoading.value, + onSubmit: (formData) { + // TODO(mistermoe): check requiredClaims and navigate to kcc flow if needed, https://github.com/TBD54566975/didpay/issues/122 + isLoading.value = true; + ref.read(rfqProvider(rfq).future).then( + (_) async { + await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ReviewPaymentPage( - rfq: Tbdex.createRfq(ref, rfqState), + exchangeId: rfq.metadata.id, paymentState: paymentState.copyWith( serviceFee: fee, paymentName: paymentName, @@ -45,26 +51,15 @@ class PaymentDetails { ), ), ), - ), - ), + ); + isLoading.value = false; + }, + onError: (_) => isLoading.value = false, ); + }, + ), + ); } - - 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? { diff --git a/lib/features/payment/review_payment_page.dart b/lib/features/payment/review_payment_page.dart index 2c6f3408..cd54df3e 100644 --- a/lib/features/payment/review_payment_page.dart +++ b/lib/features/payment/review_payment_page.dart @@ -14,26 +14,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:tbdex/tbdex.dart'; class ReviewPaymentPage extends HookConsumerWidget { - final Rfq rfq; + final String exchangeId; final PaymentState paymentState; const ReviewPaymentPage({ - required this.rfq, + required this.exchangeId, required this.paymentState, super.key, }); @override Widget build(BuildContext context, WidgetRef ref) { + final quoteStatus = ref.watch(quoteProvider); 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); - }); + Future.delayed( + Duration.zero, + () => getQuoteNotifier().startPolling(exchangeId), + ); return getQuoteNotifier().stopPolling; }, [], @@ -44,12 +44,16 @@ class ReviewPaymentPage extends HookConsumerWidget { body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.side), - child: quoteResult.when( + child: quoteStatus.when( data: (quote) => quote != null ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildHeader(context), + _buildHeader( + context, + Loc.of(context).reviewYourPayment, + Loc.of(context).makeSureInfoIsCorrect, + ), Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), @@ -64,17 +68,13 @@ class ReviewPaymentPage extends HookConsumerWidget { ), ), ), - _buildSubmitButton(context, quote.data), + _buildSubmitButton(context, ref, quote), ], ) : _loading(), loading: _loading, - error: (error, stackTrace) => Center( - child: Text( - 'Failed to get quote: $error', - style: Theme.of(context).textTheme.displaySmall, - ), - ), + error: (error, stackTrace) => + _buildErrorWidget(context, ref, quoteStatus.error.toString()), ), ), ), @@ -83,14 +83,36 @@ class ReviewPaymentPage extends HookConsumerWidget { Widget _loading() => const Center(child: CircularProgressIndicator()); - Widget _buildHeader(BuildContext context) => Padding( + Widget _buildErrorWidget( + BuildContext context, + WidgetRef ref, + String errorMessage, + ) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context, Loc.of(context).errorFound, errorMessage), + Expanded(child: Container()), + FilledButton( + onPressed: () => + ref.read(quoteProvider.notifier).startPolling(exchangeId), + child: Text(Loc.of(context).tapToRetry), + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context, String title, String subtitle) => + Padding( padding: const EdgeInsets.symmetric(vertical: Grid.xs), child: Column( children: [ Align( alignment: Alignment.topLeft, child: Text( - Loc.of(context).reviewYourPayment, + title, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), @@ -100,7 +122,7 @@ class ReviewPaymentPage extends HookConsumerWidget { Align( alignment: Alignment.topLeft, child: Text( - Loc.of(context).makeSureInfoIsCorrect, + subtitle, style: Theme.of(context).textTheme.bodyMedium, ), ), @@ -211,7 +233,7 @@ class ReviewPaymentPage extends HookConsumerWidget { ), ); - Widget _buildSubmitButton(BuildContext context, QuoteData quote) => + Widget _buildSubmitButton(BuildContext context, WidgetRef ref, Quote quote) => FilledButton( onPressed: () { Navigator.of(context).push( @@ -221,7 +243,7 @@ class ReviewPaymentPage extends HookConsumerWidget { ); }, child: Text( - '${Loc.of(context).pay} ${FeeDetails.calculateTotalAmount(quote)} ${quote.payin.currencyCode}', + '${Loc.of(context).pay} ${FeeDetails.calculateTotalAmount(quote.data)} ${quote.data.payin.currencyCode}', ), ); } diff --git a/lib/features/tbdex/quote_notifier.dart b/lib/features/tbdex/quote_notifier.dart index 456a96ee..dc1fce81 100644 --- a/lib/features/tbdex/quote_notifier.dart +++ b/lib/features/tbdex/quote_notifier.dart @@ -3,6 +3,8 @@ import 'dart:math'; import 'package:didpay/config/config.dart'; import 'package:didpay/features/account/account_providers.dart'; +import 'package:didpay/features/tbdex/tbdex_exceptions.dart'; +import 'package:didpay/shared/http_status.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:tbdex/tbdex.dart'; @@ -50,7 +52,7 @@ class QuoteAsyncNotifier extends AutoDisposeAsyncNotifier { } } on Exception catch (e) { state = AsyncValue.error( - Exception('Failed to fetch exchange: $e'), + e, StackTrace.current, ); stopPolling(); @@ -79,13 +81,17 @@ class QuoteAsyncNotifier extends AutoDisposeAsyncNotifier { final country = ref.read(countryProvider); final pfi = Config.getPfi(country); - final exchange = await TbdexHttpClient.getExchange( + final response = await TbdexHttpClient.getExchange( did, pfi?.didUri ?? '', exchangeId, ); - return exchange; + if (response.statusCode.category != HttpStatus.success) { + throw QuoteException('failed to retrieve quote', response.statusCode); + } + + return response.data ?? []; } bool _containsQuote(Exchange exchange) => diff --git a/lib/features/tbdex/tbdex_exceptions.dart b/lib/features/tbdex/tbdex_exceptions.dart new file mode 100644 index 00000000..29e9318c --- /dev/null +++ b/lib/features/tbdex/tbdex_exceptions.dart @@ -0,0 +1,26 @@ +import 'package:didpay/shared/http_status.dart'; + +// TODO(ethan-tbd): finish working on custom exceptions +class TbdexException implements Exception { + final String message; + final int errorCode; + + TbdexException(this.message, this.errorCode); + + @override + String toString() { + return '${errorCode.category}: $errorCode, $message'; + } +} + +class RfqException extends TbdexException { + RfqException(super.message, super.errorCode); +} + +class OfferingException extends TbdexException { + OfferingException(super.message, super.errorCode); +} + +class QuoteException extends TbdexException { + QuoteException(super.message, super.errorCode); +} diff --git a/lib/features/tbdex/tbdex_providers.dart b/lib/features/tbdex/tbdex_providers.dart index edd99526..4769b870 100644 --- a/lib/features/tbdex/tbdex_providers.dart +++ b/lib/features/tbdex/tbdex_providers.dart @@ -1,30 +1,36 @@ import 'package:didpay/config/config.dart'; import 'package:didpay/features/account/account_providers.dart'; import 'package:didpay/features/tbdex/quote_notifier.dart'; +import 'package:didpay/features/tbdex/tbdex_exceptions.dart'; +import 'package:didpay/shared/http_status.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:tbdex/tbdex.dart'; final offeringsProvider = FutureProvider.autoDispose>((ref) async { - try { - final country = ref.read(countryProvider); - final pfi = Config.getPfi(country); - final offerings = await TbdexHttpClient.listOfferings(pfi?.didUri ?? ''); - return offerings; - } on Exception catch (e) { - throw Exception('Failed to load offerings: $e'); - } + final country = ref.read(countryProvider); + final pfi = Config.getPfi(country); + + final response = await TbdexHttpClient.listOfferings(pfi?.didUri ?? ''); + + return response.statusCode.category == HttpStatus.success + ? response.data! + : throw OfferingException( + 'failed to fetch offerings', + response.statusCode, + ); }); final rfqProvider = FutureProvider.family.autoDispose((ref, rfq) async { - try { - final did = ref.read(didProvider); - await rfq.sign(did); + final did = ref.read(didProvider); + await rfq.sign(did); + + final response = + await TbdexHttpClient.createExchange(rfq, replyTo: rfq.metadata.from); - await TbdexHttpClient.createExchange(rfq, replyTo: rfq.metadata.from); - } on Exception catch (e) { - throw Exception('Failed to send rfq: $e'); + if (response.statusCode.category != HttpStatus.success) { + throw RfqException('failed to send rfq', response.statusCode); } }); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e02e217e..68e00bd4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -114,5 +114,7 @@ "totalToAccount": "Total to account", "totalToYou": "Total to you", "totalToRecipient": "Total to recipient", - "pay": "Pay" + "pay": "Pay", + "errorFound": "Error found", + "tapToRetry": "Tap to retry" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 486c18a2..22c732ef 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -600,6 +600,18 @@ abstract class Loc { /// In en, this message translates to: /// **'Pay'** String get pay; + + /// No description provided for @errorFound. + /// + /// In en, this message translates to: + /// **'Error found'** + String get errorFound; + + /// No description provided for @tapToRetry. + /// + /// In en, this message translates to: + /// **'Tap to retry'** + String get tapToRetry; } class _LocDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index df86e7e5..e3eccade 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -266,4 +266,10 @@ class LocEn extends Loc { @override String get pay => 'Pay'; + + @override + String get errorFound => 'Error found'; + + @override + String get tapToRetry => 'Tap to retry'; } diff --git a/lib/shared/http_status.dart b/lib/shared/http_status.dart new file mode 100644 index 00000000..f48f0c04 --- /dev/null +++ b/lib/shared/http_status.dart @@ -0,0 +1,29 @@ +enum HttpStatus { + informational, + success, + redirection, + clientError, + serverError, + unknown; + + @override + String toString() => name.substring(0, 1).toUpperCase() + name.substring(1); +} + +extension HttpStatusExtension on int { + HttpStatus get category { + if (this >= 100 && this < 200) { + return HttpStatus.informational; + } else if (this >= 200 && this < 300) { + return HttpStatus.success; + } else if (this >= 300 && this < 400) { + return HttpStatus.redirection; + } else if (this >= 400 && this < 500) { + return HttpStatus.clientError; + } else if (this >= 500 && this < 600) { + return HttpStatus.serverError; + } else { + return HttpStatus.unknown; + } + } +} diff --git a/lib/shared/json_schema_form.dart b/lib/shared/json_schema_form.dart index e7f16383..a828167d 100644 --- a/lib/shared/json_schema_form.dart +++ b/lib/shared/json_schema_form.dart @@ -8,27 +8,41 @@ import 'package:flutter_hooks/flutter_hooks.dart'; class JsonSchemaForm extends HookWidget { final String? schema; + final bool isDisabled; + final bool isLoading; final void Function(Map) onSubmit; final _formKey = GlobalKey(); final Map formData = {}; - JsonSchemaForm({required this.schema, required this.onSubmit, super.key}); + JsonSchemaForm({ + required this.schema, + required this.onSubmit, + this.isDisabled = false, + this.isLoading = false, + super.key, + }); @override Widget build(BuildContext context) { + void onPressed(Map data) { + if (schema == null) { + onSubmit(data); + } else { + if (_formKey.currentState != null && + (_formKey.currentState?.validate() ?? false)) { + _formKey.currentState?.save(); + onSubmit(data); + } + } + } + if (schema == null) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.side), - child: FilledButton( - onPressed: () => onSubmit(formData), - child: Text(Loc.of(context).next), - ), - ), + _buildNextButton(context, onPressed), ], ); } @@ -86,18 +100,7 @@ class JsonSchemaForm extends HookWidget { ), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.side), - child: FilledButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); - onSubmit(formData); - } - }, - child: Text(Loc.of(context).next), - ), - ), + _buildNextButton(context, onPressed), ], ), ); @@ -144,4 +147,33 @@ class JsonSchemaForm extends HookWidget { return null; } + + Widget _buildNextButton( + BuildContext context, + void Function(Map) onPressed, + ) => + Expanded( + child: Column( + crossAxisAlignment: isLoading + ? CrossAxisAlignment.center + : CrossAxisAlignment.stretch, + children: [ + Expanded(child: Container()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.side), + child: isDisabled + ? FilledButton( + onPressed: null, + child: Text(Loc.of(context).next), + ) + : isLoading + ? const CircularProgressIndicator() + : FilledButton( + onPressed: () => onPressed(formData), + child: Text(Loc.of(context).next), + ), + ), + ], + ), + ); } diff --git a/pubspec.lock b/pubspec.lock index 9a6fd01d..c99f3bdd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -687,7 +687,7 @@ packages: description: path: "." ref: main - resolved-ref: "16c13c9485059fb5bf5eb21a06c684138e37b2d5" + resolved-ref: "1121c339595c009244c06c904d1193f2981e0bcd" url: "https://github.com/TBD54566975/tbdex-dart.git" source: git version: "1.0.0" diff --git a/test/features/payin/payin_details_page_test.dart b/test/features/payin/payin_details_page_test.dart index 4ac57be0..cdef708c 100644 --- a/test/features/payin/payin_details_page_test.dart +++ b/test/features/payin/payin_details_page_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:didpay/features/account/account_providers.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payin/payin_details_page.dart'; import 'package:didpay/features/payin/search_payin_methods_page.dart'; @@ -10,10 +11,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:json_schema/json_schema.dart'; import 'package:tbdex/tbdex.dart'; +import 'package:web5/web5.dart'; import '../../helpers/widget_helpers.dart'; -void main() { +void main() async { + final did = await DidDht.create(); + group('PayinDetailsPage', () { final schema = JsonSchema.create( jsonDecode(r''' @@ -65,6 +69,9 @@ void main() { payinMethods: payinMethods, ), ), + overrides: [ + didProvider.overrideWithValue(did), + ], ); testWidgets('should show header', (tester) async { diff --git a/test/features/payment/review_payment_page_test.dart b/test/features/payment/review_payment_page_test.dart index 8fe07fdd..8274f840 100644 --- a/test/features/payment/review_payment_page_test.dart +++ b/test/features/payment/review_payment_page_test.dart @@ -20,18 +20,13 @@ import 'package:web5/web5.dart'; import '../../helpers/widget_helpers.dart'; 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 DidDht.create(); Widget reviewPaymentPageTestWidget({List overrides = const []}) => WidgetHelpers.testableWidget( - child: ReviewPaymentPage( - rfq: rfq, - paymentState: const PaymentState( + child: const ReviewPaymentPage( + exchangeId: '', + paymentState: PaymentState( payoutAmount: '17.00', payinCurrency: 'USD', payoutCurrency: 'MXN', diff --git a/test/features/payout/payout_details_page_test.dart b/test/features/payout/payout_details_page_test.dart index ec522a9b..25194bb5 100644 --- a/test/features/payout/payout_details_page_test.dart +++ b/test/features/payout/payout_details_page_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:didpay/features/account/account_providers.dart'; import 'package:didpay/features/home/transaction.dart'; import 'package:didpay/features/payment/payment_state.dart'; import 'package:didpay/features/payment/search_payment_types_page.dart'; @@ -10,10 +11,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:json_schema/json_schema.dart'; import 'package:tbdex/tbdex.dart'; +import 'package:web5/web5.dart'; import '../../helpers/widget_helpers.dart'; -void main() { +void main() async { + final did = await DidDht.create(); + group('PayoutDetailsPage', () { final schema = JsonSchema.create( jsonDecode(r''' @@ -65,6 +69,9 @@ void main() { payoutMethods: payoutMethods, ), ), + overrides: [ + didProvider.overrideWithValue(did), + ], ); testWidgets('should show header', (tester) async {