From 09f3b3e93c255adaed422ef7b56581c308e60407 Mon Sep 17 00:00:00 2001 From: Ethan Lee <125412902+ethan-tbd@users.noreply.github.com> Date: Fri, 25 Oct 2024 11:13:46 -0700 Subject: [PATCH] feat: add `VcsAddPage` (#323) --- lib/features/account/account_page.dart | 34 ++++---- lib/features/device/device_info_service.dart | 3 +- lib/features/did/did_form.dart | 4 +- .../{qr/qr_tile.dart => did/did_qr_tile.dart} | 4 +- lib/features/vcs/vcs_add_page.dart | 71 ++++++++++++++++ lib/features/vcs/vcs_form.dart | 61 ++++++++++++++ lib/features/vcs/vcs_notifier.dart | 21 ++++- lib/features/vcs/vcs_qr_tile.dart | 81 +++++++++++++++++++ lib/features/vcs/vcs_service.dart | 10 +++ lib/l10n/app_en.arb | 7 ++ lib/l10n/app_localizations.dart | 42 ++++++++++ lib/l10n/app_localizations_en.dart | 21 +++++ lib/main.dart | 4 +- test/features/account/account_page_test.dart | 7 +- test/features/vcs/vcs_notifier_test.dart | 8 +- test/helpers/mocks.dart | 3 + 16 files changed, 349 insertions(+), 32 deletions(-) rename lib/features/{qr/qr_tile.dart => did/did_qr_tile.dart} (97%) create mode 100644 lib/features/vcs/vcs_add_page.dart create mode 100644 lib/features/vcs/vcs_form.dart create mode 100644 lib/features/vcs/vcs_qr_tile.dart diff --git a/lib/features/account/account_page.dart b/lib/features/account/account_page.dart index ce30108b..ae17b985 100644 --- a/lib/features/account/account_page.dart +++ b/lib/features/account/account_page.dart @@ -5,6 +5,7 @@ import 'package:didpay/features/pfis/pfi.dart'; import 'package:didpay/features/pfis/pfis_add_page.dart'; import 'package:didpay/features/pfis/pfis_notifier.dart'; import 'package:didpay/features/qr/qr_tabs.dart'; +import 'package:didpay/features/vcs/vcs_add_page.dart'; import 'package:didpay/features/vcs/vcs_notifier.dart'; import 'package:didpay/l10n/app_localizations.dart'; import 'package:didpay/shared/modal/modal_manage_item.dart'; @@ -200,17 +201,17 @@ class AccountPage extends HookConsumerWidget { ), ), ), - credentials.isEmpty - ? TileContainer(child: _buildNoCredentialsTile(context)) - : ListView.builder( - physics: const BouncingScrollPhysics(), - shrinkWrap: true, - itemCount: credentials.length, - itemBuilder: (context, index) => TileContainer( + ListView.builder( + physics: const BouncingScrollPhysics(), + shrinkWrap: true, + itemCount: credentials.length + 1, + itemBuilder: (context, index) => index < credentials.length + ? TileContainer( child: _buildCredentialTile(context, ref, credentials[index]), - ), - ), + ) + : TileContainer(child: _buildAddCredentialTile(context)), + ), ], ); @@ -242,9 +243,9 @@ class AccountPage extends HookConsumerWidget { ), ); - Widget _buildNoCredentialsTile(BuildContext context) => ListTile( + Widget _buildAddCredentialTile(BuildContext context) => ListTile( title: Text( - Loc.of(context).noCredentialsIssuedYet, + Loc.of(context).addACredential, style: Theme.of(context).textTheme.titleSmall, ), leading: Container( @@ -254,11 +255,16 @@ class AccountPage extends HookConsumerWidget { color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(Grid.xxs), ), - child: Center( - child: - Icon(Icons.error, color: Theme.of(context).colorScheme.outline), + child: const Center( + child: Icon(Icons.add), ), ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const VcsAddPage()), + ); + }, ); Widget _buildFeatureFlagsList( diff --git a/lib/features/device/device_info_service.dart b/lib/features/device/device_info_service.dart index 9986786e..e207aa72 100644 --- a/lib/features/device/device_info_service.dart +++ b/lib/features/device/device_info_service.dart @@ -11,7 +11,6 @@ class DeviceInfoService { Future isPhysicalDevice() async { if (kIsWeb) { - // On web, return false as there is no physical device return false; } if (Platform.isIOS) { @@ -20,7 +19,7 @@ class DeviceInfoService { if (Platform.isAndroid) { return (await _deviceInfo.androidInfo).isPhysicalDevice; } - // Default return for unsupported platforms + return false; } } diff --git a/lib/features/did/did_form.dart b/lib/features/did/did_form.dart index 4d9b496d..284dab21 100644 --- a/lib/features/did/did_form.dart +++ b/lib/features/did/did_form.dart @@ -1,4 +1,4 @@ -import 'package:didpay/features/qr/qr_tile.dart'; +import 'package:didpay/features/did/did_qr_tile.dart'; import 'package:didpay/l10n/app_localizations.dart'; import 'package:didpay/shared/next_button.dart'; import 'package:didpay/shared/theme/grid.dart'; @@ -49,7 +49,7 @@ class DidForm extends HookConsumerWidget { ), ), ), - QrTile(didTextController: textController), + DidQrTile(didTextController: textController), NextButton( onPressed: () async => onSubmit(textController.text), title: buttonTitle, diff --git a/lib/features/qr/qr_tile.dart b/lib/features/did/did_qr_tile.dart similarity index 97% rename from lib/features/qr/qr_tile.dart rename to lib/features/did/did_qr_tile.dart index c73094af..1560cb8b 100644 --- a/lib/features/qr/qr_tile.dart +++ b/lib/features/did/did_qr_tile.dart @@ -8,10 +8,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:web5/web5.dart'; -class QrTile extends HookConsumerWidget { +class DidQrTile extends HookConsumerWidget { final TextEditingController didTextController; - const QrTile({ + const DidQrTile({ required this.didTextController, super.key, }); diff --git a/lib/features/vcs/vcs_add_page.dart b/lib/features/vcs/vcs_add_page.dart new file mode 100644 index 00000000..7001a202 --- /dev/null +++ b/lib/features/vcs/vcs_add_page.dart @@ -0,0 +1,71 @@ +import 'package:didpay/features/vcs/vcs_form.dart'; +import 'package:didpay/features/vcs/vcs_notifier.dart'; +import 'package:didpay/l10n/app_localizations.dart'; +import 'package:didpay/shared/confirmation_message.dart'; +import 'package:didpay/shared/error_message.dart'; +import 'package:didpay/shared/header.dart'; +import 'package:didpay/shared/loading_message.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class VcsAddPage extends HookConsumerWidget { + const VcsAddPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vc = useState?>(null); + + return Scaffold( + appBar: AppBar(), + body: SafeArea( + child: vc.value != null + ? vc.value!.when( + data: (_) => ConfirmationMessage( + message: Loc.of(context).credentialAdded, + ), + loading: () => + LoadingMessage(message: Loc.of(context).addingCredential), + error: (error, _) => ErrorMessage( + message: error.toString(), + onRetry: () => vc.value = null, + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Header( + title: Loc.of(context).addACredential, + subtitle: Loc.of(context).enterACredentialJwt, + ), + Expanded( + child: VcsForm( + buttonTitle: Loc.of(context).add, + onSubmit: (vcJwt) async => + _addCredential(context, ref, vcJwt, vc), + ), + ), + ], + ), + ), + ); + } + + Future _addCredential( + BuildContext context, + WidgetRef ref, + String vcJwt, + ValueNotifier?> state, + ) async { + state.value = const AsyncLoading(); + try { + final credential = await ref.read(vcsProvider.notifier).addJwt(vcJwt); + + if (context.mounted) { + state.value = AsyncData(credential); + } + } on Exception catch (e) { + state.value = AsyncError(e, StackTrace.current); + } + } +} diff --git a/lib/features/vcs/vcs_form.dart b/lib/features/vcs/vcs_form.dart new file mode 100644 index 00000000..74ce3788 --- /dev/null +++ b/lib/features/vcs/vcs_form.dart @@ -0,0 +1,61 @@ +import 'package:didpay/features/vcs/vcs_qr_tile.dart'; +import 'package:didpay/l10n/app_localizations.dart'; +import 'package:didpay/shared/next_button.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'; + +class VcsForm extends HookConsumerWidget { + final String buttonTitle; + final Future Function(String) onSubmit; + + VcsForm({required this.buttonTitle, required this.onSubmit, super.key}); + + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final focusNode = useFocusNode(); + + final textController = useTextEditingController(); + + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: Grid.side), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + focusNode: focusNode, + controller: textController, + onTapOutside: (_) => focusNode.unfocus(), + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + labelText: Loc.of(context).credentialHint, + ), + validator: (value) => value == null || value.isEmpty + ? Loc.of(context).thisFieldCannotBeEmpty + : null, + ), + ], + ), + ), + ), + VcsQrTile(credentialTextController: textController), + NextButton( + onPressed: () async => onSubmit(textController.text), + title: buttonTitle, + ), + ], + ), + ); + } +} diff --git a/lib/features/vcs/vcs_notifier.dart b/lib/features/vcs/vcs_notifier.dart index 150bd08f..fcaad834 100644 --- a/lib/features/vcs/vcs_notifier.dart +++ b/lib/features/vcs/vcs_notifier.dart @@ -1,4 +1,5 @@ import 'package:didpay/features/kcc/lib.dart'; +import 'package:didpay/features/vcs/vcs_service.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -9,13 +10,27 @@ final vcsProvider = StateNotifierProvider>( class VcsNotifier extends StateNotifier> { static const String storageKey = 'vcs'; final Box box; + final VcsService vcsService; - VcsNotifier._(this.box, List state) : super(state); + VcsNotifier._(this.box, this.vcsService, List state) : super(state); - static Future create(Box box) async { + static Future create(Box box, VcsService vcsService) async { final List vcs = await box.get(storageKey) ?? []; - return VcsNotifier._(box, vcs); + return VcsNotifier._(box, vcsService, vcs); + } + + Future addJwt(String vcJwt) async { + final parsedJwt = vcsService.parseCredential(vcJwt); + + if (state.any((elem) => elem == vcJwt)) { + return parsedJwt; + } + + state = [...state, parsedJwt]; + + await _save(); + return parsedJwt; } Future add(CredentialResponse response) async { diff --git a/lib/features/vcs/vcs_qr_tile.dart b/lib/features/vcs/vcs_qr_tile.dart new file mode 100644 index 00000000..4b0afea8 --- /dev/null +++ b/lib/features/vcs/vcs_qr_tile.dart @@ -0,0 +1,81 @@ +import 'package:didpay/features/device/device_info_service.dart'; +import 'package:didpay/features/qr/qr_scan_page.dart'; +import 'package:didpay/l10n/app_localizations.dart'; +import 'package:didpay/shared/snackbar/snackbar_service.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'; + +class VcsQrTile extends HookConsumerWidget { + final TextEditingController credentialTextController; + + const VcsQrTile({ + required this.credentialTextController, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isPhysicalDevice = useState(true); + final snackbarService = SnackbarService(); + + useEffect( + () { + Future.delayed( + Duration.zero, + () async => isPhysicalDevice.value = + await ref.read(deviceInfoServiceProvider).isPhysicalDevice(), + ); + return null; + }, + [], + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: ListTile( + leading: const Icon(Icons.qr_code), + title: Text( + Loc.of(context).scanACredentialJwt, + style: Theme.of(context).textTheme.bodyMedium, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => isPhysicalDevice.value + ? _scanQrCode( + context, + credentialTextController, + ) + : _simulateScanQrCode( + context, + credentialTextController, + snackbarService, + ), + ), + ); + } + + Future _scanQrCode( + BuildContext context, + TextEditingController credentialTextController, + ) async { + final qrValue = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const QrScanPage(), + ), + ); + + credentialTextController.text = qrValue ?? ''; + } + + Future _simulateScanQrCode( + BuildContext context, + TextEditingController credentialTextController, + SnackbarService snackbarService, + ) async { + snackbarService.showSnackBar( + context, + Loc.of(context).credentialScanUnavailable, + ); + } +} diff --git a/lib/features/vcs/vcs_service.dart b/lib/features/vcs/vcs_service.dart index fa8de695..7667534e 100644 --- a/lib/features/vcs/vcs_service.dart +++ b/lib/features/vcs/vcs_service.dart @@ -12,4 +12,14 @@ class VcsService { return credentials.isEmpty ? null : credentials; } + + String parseCredential(String vcJwt) { + try { + final _ = Jwt.decode(vcJwt); + } on Exception { + rethrow; + } + + return vcJwt; + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0f7e7c72..6329f0e5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -56,6 +56,7 @@ "serviceFeesMayApply": "Service fees may apply", "selectPaymentMethod": "Select a payment method", "didHint": "did:...", + "credentialHint": "xxxxx.yyyyy.zzzzz", "dapHint": "@local-handle/domain", "thisFieldCannotBeEmpty": "This field cannot be empty", "invalidDid": "Invalid DID", @@ -123,10 +124,14 @@ "tapToRetry": "Tap to retry", "unableToRetrieveTxns": "Unable to retrieve transactions", "addAPfi": "Add a PFI", + "addACredential": "Add a credential", + "enterACredentialJwt": "Enter a credential Json Web Token (JWT)", "noPfisFound": "No PFIs found", "startByAddingAPfi": "Start by adding a PFI", "pfiAdded": "PFI added!", + "credentialAdded": "Credential added!", "addingPfi": "Adding PFI...", + "addingCredential": "Adding credential...", "mustAddPfiBeforeSending": "Must add a PFI before sending funds!", "fetchingYourQuote": "Fetching your quote...", "fetchingPaymentInstructions": "Fetching payment instructions...", @@ -152,6 +157,8 @@ "cameraUnavailable": "Camera unavailable", "dontKnowTheirDid": "Don't know their DID? Scan their QR code instead", "dontKnowTheirDap": "Don't know their DAP? Scan their QR code instead", + "scanACredentialJwt": "Scan a credential JWT QR code", + "credentialScanUnavailable": "Credential scan unavailable", "featureFlags": "Feature flags", "lucidMode": "Lucid mode", "selectFromUnfilteredList": "Select from an unfiltered list of all your PFI offerings.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 65d432c1..9f9fe700 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -333,6 +333,12 @@ abstract class Loc { /// **'did:...'** String get didHint; + /// No description provided for @credentialHint. + /// + /// In en, this message translates to: + /// **'xxxxx.yyyyy.zzzzz'** + String get credentialHint; + /// No description provided for @dapHint. /// /// In en, this message translates to: @@ -651,6 +657,18 @@ abstract class Loc { /// **'Add a PFI'** String get addAPfi; + /// No description provided for @addACredential. + /// + /// In en, this message translates to: + /// **'Add a credential'** + String get addACredential; + + /// No description provided for @enterACredentialJwt. + /// + /// In en, this message translates to: + /// **'Enter a credential Json Web Token (JWT)'** + String get enterACredentialJwt; + /// No description provided for @noPfisFound. /// /// In en, this message translates to: @@ -669,12 +687,24 @@ abstract class Loc { /// **'PFI added!'** String get pfiAdded; + /// No description provided for @credentialAdded. + /// + /// In en, this message translates to: + /// **'Credential added!'** + String get credentialAdded; + /// No description provided for @addingPfi. /// /// In en, this message translates to: /// **'Adding PFI...'** String get addingPfi; + /// No description provided for @addingCredential. + /// + /// In en, this message translates to: + /// **'Adding credential...'** + String get addingCredential; + /// No description provided for @mustAddPfiBeforeSending. /// /// In en, this message translates to: @@ -819,6 +849,18 @@ abstract class Loc { /// **'Don\'t know their DAP? Scan their QR code instead'** String get dontKnowTheirDap; + /// No description provided for @scanACredentialJwt. + /// + /// In en, this message translates to: + /// **'Scan a credential JWT QR code'** + String get scanACredentialJwt; + + /// No description provided for @credentialScanUnavailable. + /// + /// In en, this message translates to: + /// **'Credential scan unavailable'** + String get credentialScanUnavailable; + /// No description provided for @featureFlags. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 487fe58f..da60b68a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -130,6 +130,9 @@ class LocEn extends Loc { @override String get didHint => 'did:...'; + @override + String get credentialHint => 'xxxxx.yyyyy.zzzzz'; + @override String get dapHint => '@local-handle/domain'; @@ -293,6 +296,12 @@ class LocEn extends Loc { @override String get addAPfi => 'Add a PFI'; + @override + String get addACredential => 'Add a credential'; + + @override + String get enterACredentialJwt => 'Enter a credential Json Web Token (JWT)'; + @override String get noPfisFound => 'No PFIs found'; @@ -302,9 +311,15 @@ class LocEn extends Loc { @override String get pfiAdded => 'PFI added!'; + @override + String get credentialAdded => 'Credential added!'; + @override String get addingPfi => 'Adding PFI...'; + @override + String get addingCredential => 'Adding credential...'; + @override String get mustAddPfiBeforeSending => 'Must add a PFI before sending funds!'; @@ -377,6 +392,12 @@ class LocEn extends Loc { @override String get dontKnowTheirDap => 'Don\'t know their DAP? Scan their QR code instead'; + @override + String get scanACredentialJwt => 'Scan a credential JWT QR code'; + + @override + String get credentialScanUnavailable => 'Credential scan unavailable'; + @override String get featureFlags => 'Feature flags'; diff --git a/lib/main.dart b/lib/main.dart index 80490ecb..fa6aab49 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:didpay/features/pfis/pfi.dart'; import 'package:didpay/features/pfis/pfis_notifier.dart'; import 'package:didpay/features/pfis/pfis_service.dart'; import 'package:didpay/features/vcs/vcs_notifier.dart'; +import 'package:didpay/features/vcs/vcs_service.dart'; import 'package:didpay/shared/logger.dart'; import 'package:didpay/shared/storage/storage_providers.dart'; import 'package:flutter/material.dart'; @@ -54,7 +55,8 @@ Future> notifierOverrides() async { if (pfisBox.isEmpty) await pfisNotifier.add(tbdPfiDid); final vcsBox = await Hive.openBox(VcsNotifier.storageKey); - final vcsNotifier = await VcsNotifier.create(vcsBox); + + final vcsNotifier = await VcsNotifier.create(vcsBox, VcsService()); final featureFlagsBox = await Hive.openBox(FeatureFlagsNotifier.storageKey); final featureFlagsNotifier = diff --git a/test/features/account/account_page_test.dart b/test/features/account/account_page_test.dart index 20d1b19f..0213ebf7 100644 --- a/test/features/account/account_page_test.dart +++ b/test/features/account/account_page_test.dart @@ -56,13 +56,10 @@ void main() { expect(find.text('Issued credentials'), findsOneWidget); }); - testWidgets('should show no credentials issued yet tile', (tester) async { + testWidgets('should show add a credential tile', (tester) async { await tester.pumpWidget(accountPageTestWidget()); - expect( - find.widgetWithText(ListTile, 'No credentials issued yet'), - findsOneWidget, - ); + expect(find.widgetWithText(ListTile, 'Add a credential'), findsOneWidget); }); testWidgets('should show DidQrTabs on tap of qr icon', (tester) async { diff --git a/test/features/vcs/vcs_notifier_test.dart b/test/features/vcs/vcs_notifier_test.dart index 92c43685..dd7bd619 100644 --- a/test/features/vcs/vcs_notifier_test.dart +++ b/test/features/vcs/vcs_notifier_test.dart @@ -7,9 +7,11 @@ import '../../helpers/mocks.dart'; void main() { late MockBox mockBox; + late MockVcsService mockVcsService; setUp(() { mockBox = MockBox(); + mockVcsService = MockVcsService(); }); group('VcsNotifier', () { @@ -17,7 +19,7 @@ void main() { final initialVcs = ['fake_cred']; when(() => mockBox.get(VcsNotifier.storageKey)).thenReturn(initialVcs); - final notifier = await VcsNotifier.create(mockBox); + final notifier = await VcsNotifier.create(mockBox, mockVcsService); expect(notifier.state, initialVcs); verify(() => mockBox.get(VcsNotifier.storageKey)).called(1); @@ -35,7 +37,7 @@ void main() { ), ).thenAnswer((_) async {}); - final notifier = await VcsNotifier.create(mockBox); + final notifier = await VcsNotifier.create(mockBox, mockVcsService); final addedVc = await notifier .add(CredentialResponse(credential: newVc, transactionId: null)); @@ -59,7 +61,7 @@ void main() { when(() => mockBox.put(VcsNotifier.storageKey, any())) .thenAnswer((_) async {}); - final notifier = await VcsNotifier.create(mockBox); + final notifier = await VcsNotifier.create(mockBox, mockVcsService); await notifier.remove(credentialToRemove); expect(notifier.state, []); diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index b7277e35..cebcc207 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -12,6 +12,7 @@ import 'package:didpay/features/tbdex/tbdex_service.dart'; import 'package:didpay/features/transaction/transaction.dart'; import 'package:didpay/features/transaction/transaction_notifier.dart'; import 'package:didpay/features/vcs/vcs_notifier.dart'; +import 'package:didpay/features/vcs/vcs_service.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; @@ -28,6 +29,8 @@ class MockSharedPreferences extends Mock implements SharedPreferences {} class MockPfisService extends Mock implements PfisService {} +class MockVcsService extends Mock implements VcsService {} + class MockTbdexService extends Mock implements TbdexService {} class MockBox extends Mock implements Box {}