From 68481af3456b5ecacea4f36b0702d140241bd534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Hlatk=C3=BD?= Date: Tue, 7 May 2024 19:03:21 +0200 Subject: [PATCH 1/2] AVM-29 | Fix Android Deep Link domain auto verify --- CHANGELOG.md | 4 ++++ android/app/src/main/AndroidManifest.xml | 9 +++++++++ pubspec.yaml | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba2ede..5b00a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2024-05-07 - v1.0.0(25) + +- Android: Fix auto verify Deep links - no need to manually enable domain association + ## 2024-05-07 - v1.0.0(24) - Fix issue that no document was open when QR code was scanned diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 94a7807..4f3fa2a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -58,6 +58,15 @@ + + + + + + + + + diff --git a/pubspec.yaml b/pubspec.yaml index 720f0b4..76e4bcf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: autogram description: "Autogram v mobile" publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.0.0+24 +version: 1.0.0+25 environment: sdk: '>=3.2.3 <4.0.0' From a19268bd83f1eee9b72523ad80d72cb73ece7566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Hlatk=C3=BD?= Date: Tue, 28 May 2024 15:31:33 +0200 Subject: [PATCH 2/2] AVM-32 | Onboarding - Privacy Policy and Terms of Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - completely refactor Onboarding flow - add separate setting for Privacy Policy document version - prepare AppBloc for event bus - simplify code Squashed commit of the following: commit 88f005faead2a083a017a916cd05704cca67a3e8 Author: Matej Hlatký Date: Tue May 28 15:15:04 2024 +0200 Cleanup AppService usage commit 8b34266390d0495a66308ac05d04ee20ee7148d3 Author: Matej Hlatký Date: Tue May 28 15:03:04 2024 +0200 Refactor to use event bus commit bfa868a8f3ffa03b06bee5077cd0f0fb201d6dd7 Author: Matej Hlatký Date: Tue May 28 14:24:21 2024 +0200 Impl. custom TransformValueListenable commit 151b4de414462b3b2bfff1da49ad9bb219907605 Author: Matej Hlatký Date: Tue May 28 13:50:18 2024 +0200 Cleanup commit 43d81f016e4cc77ac123cbf1d5653056716fa57e Author: Matej Hlatký Date: Tue May 28 13:40:00 2024 +0200 Update TODOs commit 1509e17a1009bf6038c21aca039503a45a9215e4 Author: Matej Hlatký Date: Tue May 28 12:40:09 2024 +0200 Working with onboardingRequired ValueListenable commit 82036fac7c81e01edf4afefc618535a66d11b3d6 Author: Matej Hlatký Date: Tue May 28 11:48:29 2024 +0200 Cleanup commit f359c9720b8b671e1b81bffe5bb634a8563b4971 Author: Matej Hlatký Date: Tue May 28 11:44:06 2024 +0200 Update docs commit 49609f34657d77924e5f0e555a01a9dd8abf4d2a Author: Matej Hlatký Date: Tue May 28 11:29:03 2024 +0200 Move setter invocation into screen itself commit 63e0ef5c53d442404404b9fbc2862cc6d9b68ed0 Author: Matej Hlatký Date: Tue May 28 11:22:13 2024 +0200 Update comments commit 399c9d1331b073bdd35278c604f68ba8814ad5d8 Author: Matej Hlatký Date: Fri May 24 16:51:29 2024 +0200 Refactor to use static class However now the callbacks are more complex and also returning result does not work commit b5b65e8ed66e2b2509599b81015909080b8c55f4 Author: Matej Hlatký Date: Fri May 24 16:12:15 2024 +0200 Refactor GetDocumentVersionUseCase commit 2f08fbd1bf430ff6cebdbc594d0c33993a1c721a Author: Matej Hlatký Date: Thu May 23 21:20:49 2024 +0200 Drop Fragment; fix step number commit 85c70b93cd0953d2357ebc911d73ae06b73fb03a Author: Matej Hlatký Date: Thu May 23 20:39:36 2024 +0200 Rename settings keys becasue of underlying type change commit f97b6674b5e3307a7202511195e4816d13f7ff96 Author: Matej Hlatký Date: Thu May 23 19:39:02 2024 +0200 Remove unused extension commit 91b85c0442a5d715b1a436e9bdbad1d14ef888aa Author: Matej Hlatký Date: Thu May 23 19:29:09 2024 +0200 pub upgrade commit ddf9158c29802c0acaa769a7dd83b51d0bdc7497 Author: Matej Hlatký Date: Tue May 21 21:00:34 2024 +0200 Impl. DocumentVersionRepository to get the version from HTML page commit 67d91a9cc00134be7e944a34533a38a45f256904 Author: Matej Hlatký Date: Tue May 21 20:07:09 2024 +0200 Update docs commit 43dfc3f3d525e7786f49e66d47c96d1b68906ff3 Author: Matej Hlatký Date: Tue May 21 20:00:49 2024 +0200 Combine two screens into one commit d23c873cf0b2883b17bc130e160548e3f3b31c1c Author: Matej Hlatký Date: Tue May 21 18:30:12 2024 +0200 Update TODO comment commit 06cccc2169c068f6a24c996481f220b1e3630176 Author: Matej Hlatký Date: Tue May 21 17:52:27 2024 +0200 Update Settings model commit da125444cb7209898a980f8827ee329768286a17 Author: Matej Hlatký Date: Tue May 21 17:46:03 2024 +0200 Add TODO comment commit 90e4d2f281da9acf6cb7d640cebc6853776723c2 Author: Matej Hlatký Date: Tue May 21 17:28:37 2024 +0200 Combine two screens into one commit d24077f924d87b39571c3a35be5d0884a8b485ae Author: Matej Hlatký Date: Tue May 21 16:28:18 2024 +0200 Update onboarding accept documents screen commit efc4d89ed79dc4e6450e1471bd8d9d0308d513c3 Author: Matej Hlatký Date: Tue May 21 16:14:42 2024 +0200 Extract AcceptDocumentFragment commit 92e1223732727ec6e68610264f63e05d48d1fd6c Author: Matej Hlatký Date: Tue May 21 15:59:48 2024 +0200 Update OnboardingAcceptTermsOfServiceScreen commit c7f2c1703378fb98a6064d4e4881fdecb8fcc9a3 Author: Matej Hlatký Date: Tue May 21 15:54:15 2024 +0200 Fix OptionPicker option with longer text commit 0ee8127608633163c02532e9d5a147921b191f24 Author: Matej Hlatký Date: Tue May 21 15:45:17 2024 +0200 Update menu commit fe666e4b49250215fc88750a8bb52116be060574 Author: Matej Hlatký Date: Tue May 21 15:35:08 2024 +0200 Update terms of service and privacy policy document URL commit 167b2fa9fc3ca62a92cd3849b25b56221b2cca3d Author: Matej Hlatký Date: Thu May 16 17:55:11 2024 +0200 pub upgrade + limit chopper version --- README.md | 16 +- lib/app.dart | 46 ++--- lib/bloc/app_bloc.dart | 16 ++ lib/bloc/app_event.dart | 19 ++ lib/certificate_extensions.dart | 31 ---- lib/data/settings.dart | 21 ++- lib/di.config.dart | 69 +++---- .../transform_value_listenable.dart | 42 +++++ lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_sk.dart | 9 + lib/l10n/app_sk.arb | 3 + lib/main.dart | 5 - lib/ui/onboarding.dart | 127 +++++++++++++ lib/ui/screens/about_screen.dart | 4 +- lib/ui/screens/main_menu_screen.dart | 26 ++- lib/ui/screens/main_screen.dart | 121 ++++++------- .../onboarding_accept_document_screen.dart | 171 ++++++++++++++++++ ...arding_accept_terms_of_service_screen.dart | 117 ------------ .../screens/onboarding_finished_screen.dart | 17 +- lib/ui/screens/onboarding_screen.dart | 103 ----------- ...ing_select_signing_certificate_screen.dart | 31 ++-- lib/ui/screens/settings_screen.dart | 18 +- lib/ui/screens/show_document_screen.dart | 62 +++++++ .../screens/show_terms_of_service_screen.dart | 41 ----- lib/ui/widgets/option_picker.dart | 14 +- .../get_document_version_use_case.dart | 59 ++++++ lib/widgetbook_app.directories.g.dart | 18 +- pubspec.lock | 46 +++-- pubspec.yaml | 4 +- .../transform_value_listenable_test.dart | 47 +++++ .../get_document_version_use_case_test.dart | 96 ++++++++++ 31 files changed, 925 insertions(+), 492 deletions(-) create mode 100644 lib/bloc/app_bloc.dart create mode 100644 lib/bloc/app_event.dart create mode 100644 lib/foundation/transform_value_listenable.dart create mode 100644 lib/ui/onboarding.dart create mode 100644 lib/ui/screens/onboarding_accept_document_screen.dart delete mode 100644 lib/ui/screens/onboarding_accept_terms_of_service_screen.dart delete mode 100644 lib/ui/screens/onboarding_screen.dart create mode 100644 lib/ui/screens/show_document_screen.dart delete mode 100644 lib/ui/screens/show_terms_of_service_screen.dart create mode 100644 lib/use_case/get_document_version_use_case.dart create mode 100644 test/foundation/transform_value_listenable_test.dart create mode 100644 test/use_case/get_document_version_use_case_test.dart diff --git a/README.md b/README.md index f42e193..8cad7fc 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,12 @@ Flutter app for Android and iOS. ### Onboarding -User onboarding - starts with one [`OnboardingScreen`](lib/ui/screens/onboarding_screen.dart): +User onboarding - starts with one [`Onboarding`](lib/ui/onboarding.dart): -1. [`OnboardingAcceptTermsOfServiceScreen`](lib/ui/screens/onboarding_accept_terms_of_service_screen.dart) -2. [`OnboardingSelectSigningCertificateScreen`](lib/ui/screens/onboarding_select_signing_certificate_screen.dart) -3. [`OnboardingFinishedScreen`](lib/ui/screens/onboarding_finished_screen.dart) +1. Accept Privacy Policy using [`OnboardingAcceptDocumentScreen`](lib/ui/screens/onboarding_accept_document_screen.dart) +2. Accept Terms of Service using same [`OnboardingAcceptDocumentScreen`](lib/ui/screens/onboarding_accept_document_screen.dart) +3. optionally [`OnboardingSelectSigningCertificateScreen`](lib/ui/screens/onboarding_select_signing_certificate_screen.dart) +4. Presenting finish - [`OnboardingFinishedScreen`](lib/ui/screens/onboarding_finished_screen.dart) ### Sign single document @@ -48,6 +49,13 @@ Signing of single (PDF, TXT, image, eForms XML, ...) document using 6. [`PresentSignedDocumentScreen`](lib/ui/screens/present_signed_document_screen.dart) - here, the (success / error) result is presented and signed document is saved into "Downloads". +### Remote document signing + +Similar to [Sign single document](#sign-single-document), but starts with: + +- [`StartRemoteDocumentSigningScreen`](lib/ui/screens/start_remote_document_signing_screen.dart) +- [`QRCodeScannerScreen`](lib/ui/screens/qr_code_scanner_screen.dart) + ## Scripts FVM init and Pub get: diff --git a/lib/app.dart b/lib/app.dart index b4b0782..20fdb11 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'app_navigator_observer.dart'; import 'app_service.dart'; +import 'bloc/app_bloc.dart'; +import 'di.dart'; import 'l10n/app_localizations.dart'; import 'strings_context.dart'; import 'ui/app_theme.dart'; @@ -10,7 +12,7 @@ import 'ui/screens/main_screen.dart'; /// Main Material app. /// -/// Consumes [AppService] to read its [AppService.incomingUri]. +/// Gets [AppService] to read its [AppService.incomingUri]. /// /// Home is [MainScreen]. class App extends StatelessWidget { @@ -18,44 +20,32 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) { - // TODO Move "Consumer" and additional code into MainScreen - final home = Consumer( - builder: (context, appService, _) { - return ValueListenableBuilder( - valueListenable: appService.incomingUri, - builder: (context, incomingUri, _) { - // TODO Convert to stateful and show modal dialog with question whether to start over with different input file + final appService = getIt.get(); - return MainScreen( - incomingUri: incomingUri, - ); - }, + final home = ValueListenableBuilder( + valueListenable: appService.incomingUri, + builder: (context, incomingUri, _) { + // TODO Convert to stateful and show modal dialog with question whether to start over with different input file + + return MainScreen( + incomingUri: incomingUri, ); }, ); - return MaterialApp( + final app = MaterialApp( title: context.strings.appTitle, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, debugShowCheckedModeBanner: false, navigatorObservers: [AppNavigatorObserver()], theme: appTheme(context, brightness: Brightness.light), + home: home, + ); - // Normally, setting home Widget would be sufficient - // However need to assign arguments to RouteSettings so it can be read - // back when navigated from OnboardingScreen - onGenerateRoute: (RouteSettings settings) { - if (settings.name == '/') { - return MaterialPageRoute( - // ignore: prefer_const_constructors, prefer_const_literals_to_create_immutables - settings: RouteSettings(name: '/', arguments: {}), - builder: (_) => home, - ); - } - - return null; - }, + return BlocProvider( + create: (_) => AppBloc(), + child: app, ); } } diff --git a/lib/bloc/app_bloc.dart b/lib/bloc/app_bloc.dart new file mode 100644 index 0000000..ded3b2d --- /dev/null +++ b/lib/bloc/app_bloc.dart @@ -0,0 +1,16 @@ +import 'package:flutter_bloc/flutter_bloc.dart' show Bloc; + +import '../app.dart'; +import 'app_event.dart'; + +export 'app_event.dart'; + +/// Bloc for the [App], where event type is used also as state, so it is +/// used as simple event bus. +class AppBloc extends Bloc { + AppBloc() : super(null) { + on((event, emit) { + emit(event); + }); + } +} diff --git a/lib/bloc/app_event.dart b/lib/bloc/app_event.dart new file mode 100644 index 0000000..26cc7ea --- /dev/null +++ b/lib/bloc/app_event.dart @@ -0,0 +1,19 @@ +import 'package:flutter/foundation.dart'; + +import 'app_bloc.dart'; + +/// Event for the [AppBloc]. +@immutable +sealed class AppEvent { + const AppEvent(); + + @override + String toString() { + return "$runtimeType()"; + } +} + +/// "Request open file" event. +class RequestOpenFileEvent extends AppEvent { + const RequestOpenFileEvent() : super(); +} diff --git a/lib/certificate_extensions.dart b/lib/certificate_extensions.dart index b4fdb6c..15ff161 100644 --- a/lib/certificate_extensions.dart +++ b/lib/certificate_extensions.dart @@ -11,35 +11,4 @@ extension CertificateExtensions on Certificate { return x509CertificateData.tbsCertificate!; } - - /// Gets the usage label text. - String? get usageLabel { - // Issuer SN: SVK eID ACA2 / SVK eID PCA / SVK eID SCA - // Slot: QES / ES - // keyUsage: KeyUsage.NON_REPUDIATION / KeyUsage.KEY_ENCIPHERMENT / KeyUsage.DATA_ENCIPHERMENT - // extKeyUsage: null / ExtendedKeyUsage.CLIENT_AUTH / null - - final cert = tbsCertificate; - final keyUsage = cert.extensions?.keyUsage; - - if (isQualified) { - return "Kvalifikovaný certifikát pre elektronický podpis"; - } - - if (keyUsage == null) { - return null; - } - - if (keyUsage.contains(KeyUsage.DIGITAL_SIGNATURE)) { - return "Certifikát pre elektronický podpis"; - } - - if (keyUsage.contains(KeyUsage.DATA_ENCIPHERMENT)) { - return "Šifrovací certifikát"; - } - - return keyUsage - .map((e) => e.name.toLowerCase().replaceAll('_', ' ')) - .join(", "); - } } diff --git a/lib/data/settings.dart b/lib/data/settings.dart index fb65bd7..99845db 100644 --- a/lib/data/settings.dart +++ b/lib/data/settings.dart @@ -7,8 +7,11 @@ import 'signature_type.dart'; /// Interface for general app settings. abstract interface class ISettings { - /// Accepted Terms of Service (ToS) document version value. - ValueNotifier get acceptedTermsOfServiceVersion; + /// Accepted Privacy Policy document version value. + ValueNotifier get acceptedPrivacyPolicyVersion; + + /// Accepted Terms of Service document version value. + ValueNotifier get acceptedTermsOfServiceVersion; /// The signing container value. ValueNotifier get signingPdfContainer; @@ -27,11 +30,19 @@ abstract interface class ISettings { /// /// Uses **Shared Preferences** - need to call [Settings.initialize] before use. // TODO Make only "Settings" type and private _SettingsImpl that will be returned by factory fun +// TODO Also register it using Injectable class Settings with NotifiedPreferences implements ISettings { @override - late final ValueNotifier acceptedTermsOfServiceVersion = - createSetting( - key: 'tos.version.accepted', + late final ValueNotifier acceptedPrivacyPolicyVersion = + createSetting( + key: 'doc.pp.version.accepted', + initialValue: null, + ); + + @override + late final ValueNotifier acceptedTermsOfServiceVersion = + createSetting( + key: 'doc.tos.version.accepted', initialValue: null, ); diff --git a/lib/di.config.dart b/lib/di.config.dart index 9a05946..71ecf93 100644 --- a/lib/di.config.dart +++ b/lib/di.config.dart @@ -8,26 +8,27 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i15; -import 'dart:io' as _i16; +import 'dart:async' as _i16; +import 'dart:io' as _i17; -import 'package:autogram_sign/autogram_sign.dart' as _i6; +import 'package:autogram_sign/autogram_sign.dart' as _i7; import 'package:eidmsdk/eidmsdk.dart' as _i4; -import 'package:eidmsdk/types.dart' as _i12; -import 'package:flutter/foundation.dart' as _i11; +import 'package:eidmsdk/types.dart' as _i13; +import 'package:flutter/foundation.dart' as _i12; import 'package:get_it/get_it.dart' as _i1; import 'package:injectable/injectable.dart' as _i2; import 'app_service.dart' as _i3; -import 'bloc/create_document_cubit.dart' as _i14; -import 'bloc/paired_device_list_cubit.dart' as _i7; -import 'bloc/present_signed_document_cubit.dart' as _i8; -import 'bloc/preview_document_cubit.dart' as _i9; -import 'bloc/select_signing_certificate_cubit.dart' as _i10; -import 'bloc/sign_document_cubit.dart' as _i13; -import 'data/pdf_signing_option.dart' as _i17; -import 'di.dart' as _i18; +import 'bloc/create_document_cubit.dart' as _i15; +import 'bloc/paired_device_list_cubit.dart' as _i8; +import 'bloc/present_signed_document_cubit.dart' as _i9; +import 'bloc/preview_document_cubit.dart' as _i10; +import 'bloc/select_signing_certificate_cubit.dart' as _i11; +import 'bloc/sign_document_cubit.dart' as _i14; +import 'data/pdf_signing_option.dart' as _i18; +import 'di.dart' as _i19; import 'services/encryption_key_registry.dart' as _i5; +import 'use_case/get_document_version_use_case.dart' as _i6; extension GetItInjectableX on _i1.GetIt { // initializes the registration of main-scope dependencies inside of GetIt @@ -44,53 +45,55 @@ extension GetItInjectableX on _i1.GetIt { gh.singleton<_i3.AppService>(() => _i3.AppService()); gh.lazySingleton<_i4.Eidmsdk>(() => extrernalModule.eidmsdk); gh.singleton<_i5.EncryptionKeyRegistry>(() => _i5.EncryptionKeyRegistry()); - gh.lazySingleton<_i6.IAutogramService>( + gh.lazySingleton<_i6.GetDocumentVersionUseCase>( + () => _i6.GetDocumentVersionUseCase()); + gh.lazySingleton<_i7.IAutogramService>( () => extrernalModule.create(gh<_i5.EncryptionKeyRegistry>())); - gh.factory<_i7.PairedDeviceListCubit>( - () => _i7.PairedDeviceListCubit(service: gh<_i6.IAutogramService>())); - gh.factoryParam<_i8.PresentSignedDocumentCubit, - _i6.SignDocumentResponseBody, dynamic>(( + gh.factory<_i8.PairedDeviceListCubit>( + () => _i8.PairedDeviceListCubit(service: gh<_i7.IAutogramService>())); + gh.factoryParam<_i9.PresentSignedDocumentCubit, + _i7.SignDocumentResponseBody, dynamic>(( signedDocument, _, ) => - _i8.PresentSignedDocumentCubit( + _i9.PresentSignedDocumentCubit( appService: gh<_i3.AppService>(), signedDocument: signedDocument, )); - gh.factoryParam<_i9.PreviewDocumentCubit, String, dynamic>(( + gh.factoryParam<_i10.PreviewDocumentCubit, String, dynamic>(( documentId, _, ) => - _i9.PreviewDocumentCubit( - service: gh<_i6.IAutogramService>(), + _i10.PreviewDocumentCubit( + service: gh<_i7.IAutogramService>(), documentId: documentId, )); - gh.factoryParam<_i10.SelectSigningCertificateCubit, - _i11.ValueNotifier<_i12.Certificate?>, dynamic>(( + gh.factoryParam<_i11.SelectSigningCertificateCubit, + _i12.ValueNotifier<_i13.Certificate?>, dynamic>(( signingCertificate, _, ) => - _i10.SelectSigningCertificateCubit( + _i11.SelectSigningCertificateCubit( eidmsdk: gh<_i4.Eidmsdk>(), signingCertificate: signingCertificate, )); - gh.factoryParam<_i13.SignDocumentCubit, String, _i12.Certificate>(( + gh.factoryParam<_i14.SignDocumentCubit, String, _i13.Certificate>(( documentId, certificate, ) => - _i13.SignDocumentCubit( - service: gh<_i6.IAutogramService>(), + _i14.SignDocumentCubit( + service: gh<_i7.IAutogramService>(), eidmsdk: gh<_i4.Eidmsdk>(), documentId: documentId, certificate: certificate, )); - gh.factoryParam<_i14.CreateDocumentCubit, _i15.FutureOr<_i16.File>, - _i17.PdfSigningOption>(( + gh.factoryParam<_i15.CreateDocumentCubit, _i16.FutureOr<_i17.File>, + _i18.PdfSigningOption>(( file, pdfSigningOption, ) => - _i14.CreateDocumentCubit( - service: gh<_i6.IAutogramService>(), + _i15.CreateDocumentCubit( + service: gh<_i7.IAutogramService>(), file: file, pdfSigningOption: pdfSigningOption, )); @@ -98,7 +101,7 @@ extension GetItInjectableX on _i1.GetIt { } } -class _$ExtrernalModule extends _i18.ExtrernalModule { +class _$ExtrernalModule extends _i19.ExtrernalModule { @override _i4.Eidmsdk get eidmsdk => _i4.Eidmsdk(); } diff --git a/lib/foundation/transform_value_listenable.dart b/lib/foundation/transform_value_listenable.dart new file mode 100644 index 0000000..ca2bc33 --- /dev/null +++ b/lib/foundation/transform_value_listenable.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +/// Produces single [ValueListenable] from source [listenable1] & [listenable2] +/// using [transformation] function. +class TransformValueListenable { + late final ValueNotifier _notifier; + + final ValueListenable listenable1; + final ValueListenable listenable2; + final R Function(T1 value1, T2 value2) transformation; + + /// Resulting [ValueListenable] from source [listenable1] & [listenable2] + /// after [transformation] function was applied. + ValueListenable get listenable => _notifier; + + TransformValueListenable({ + required this.listenable1, + required this.listenable2, + required this.transformation, + }) { + _notifier = ValueNotifier(_getValue()); + + listenable1.addListener(_commonListener); + listenable2.addListener(_commonListener); + } + + void _commonListener() { + _notifier.value = _getValue(); + } + + R _getValue() { + final value1 = listenable1.value; + final value2 = listenable2.value; + + return transformation(value1, value2); + } + + void dispose() { + listenable1.removeListener(_commonListener); + listenable2.removeListener(_commonListener); + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 69f3e8a..170ff77 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -313,12 +313,30 @@ abstract class AppLocalizations { /// **'{count, plural, zero{žiadne} one{1 zariadenie} few{{count} zariadenia} other{{count} zariadení}}'** String pairedDevicesSummary(num count); + /// No description provided for @privacyPolicyTitle. + /// + /// In sk, this message translates to: + /// **'Ochrana osobných údajov'** + String get privacyPolicyTitle; + + /// No description provided for @privacyPolicyUrl. + /// + /// In sk, this message translates to: + /// **'https://sluzby.slovensko.digital/autogram-v-mobile/ochrana-osobnych-udajov'** + String get privacyPolicyUrl; + /// No description provided for @termsOfServiceTitle. /// /// In sk, this message translates to: /// **'Podmienky používania'** String get termsOfServiceTitle; + /// No description provided for @termsOfServiceUrl. + /// + /// In sk, this message translates to: + /// **'https://sluzby.slovensko.digital/autogram-v-mobile/vseobecne-obchodne-podmienky'** + String get termsOfServiceUrl; + /// No description provided for @aboutTitle. /// /// In sk, this message translates to: diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index dcc8577..6d95bb8 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -163,9 +163,18 @@ class AppLocalizationsSk extends AppLocalizations { return '$_temp0'; } + @override + String get privacyPolicyTitle => 'Ochrana osobných údajov'; + + @override + String get privacyPolicyUrl => 'https://sluzby.slovensko.digital/autogram-v-mobile/ochrana-osobnych-udajov'; + @override String get termsOfServiceTitle => 'Podmienky používania'; + @override + String get termsOfServiceUrl => 'https://sluzby.slovensko.digital/autogram-v-mobile/vseobecne-obchodne-podmienky'; + @override String get aboutTitle => 'O aplikácii'; diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 1faf542..75b11d6 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -41,7 +41,10 @@ "signingCertificateTitle": "Predvolený podpisový certifikát", "pairedDevicesTitle": "Spárované zariadenia", "pairedDevicesSummary": "{count, plural, zero{žiadne} one{1 zariadenie} few{{count} zariadenia} other{{count} zariadení}}", + "privacyPolicyTitle": "Ochrana osobných údajov", + "privacyPolicyUrl": "https://sluzby.slovensko.digital/autogram-v-mobile/ochrana-osobnych-udajov", "termsOfServiceTitle": "Podmienky používania", + "termsOfServiceUrl": "https://sluzby.slovensko.digital/autogram-v-mobile/vseobecne-obchodne-podmienky", "aboutTitle": "O aplikácii", "eidSDKLicenseText": "Na komunikáciu s čipom občianskeho preukazu je použitá knižnica eID mSDK od Ministerstva vnútra Slovenskej republiky. Knižnica eID mSDK a podmienky jej použitia sú zverejnené na\u00A0stránke\n„https://github.com/eidmsdk“.", diff --git a/lib/main.dart b/lib/main.dart index cd6c5af..0cbe831 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,12 +2,10 @@ import 'dart:developer' as developer; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/widgets.dart' show WidgetsFlutterBinding, runApp; -import 'package:get_it/get_it.dart'; import 'package:logging/logging.dart' show Level, LogRecord, Logger; import 'package:provider/provider.dart' show MultiProvider, Provider; import 'app.dart'; -import 'app_service.dart'; import 'data/settings.dart'; import 'di.dart'; import 'firebase_options.dart'; @@ -38,9 +36,6 @@ void main() async { providers: [ // ISettings Provider.value(value: settings), - - // AppService - Provider.value(value: GetIt.instance.get()), ], child: const App(), ), diff --git a/lib/ui/onboarding.dart b/lib/ui/onboarding.dart new file mode 100644 index 0000000..d05e8c9 --- /dev/null +++ b/lib/ui/onboarding.dart @@ -0,0 +1,127 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/app_bloc.dart'; +import '../data/settings.dart'; +import '../strings_context.dart'; +import 'screens/onboarding_accept_document_screen.dart'; +import 'screens/onboarding_finished_screen.dart'; +import 'screens/onboarding_select_signing_certificate_screen.dart'; + +/// Helper for Onboarding flow. +/// +/// Reads [ISettings]. +/// +/// Onboarding flow: +/// 1. Accept Privacy Policy - [OnboardingAcceptDocumentScreen] +/// 2. Accept Terms of Service - [OnboardingAcceptDocumentScreen] +/// 3. Select Signing Certificate - [OnboardingSelectSigningCertificateScreen] +/// 4. Success - [OnboardingFinishedScreen] +abstract class Onboarding { + static final ValueNotifier _onboardingRequired = ValueNotifier(null); + + /// Indicates whether starting Onboarding is required. + static ValueListenable get onboardingRequired => _onboardingRequired; + + /// Refresh [onboardingRequired] value. + static void refreshOnboardingRequired(BuildContext context) { + final settings = context.read(); + bool flag; + + if (settings.acceptedPrivacyPolicyVersion.value == null) { + flag = true; + } else if (settings.acceptedTermsOfServiceVersion.value == null) { + flag = true; + } else { + flag = false; + } + + _onboardingRequired.value = flag; + } + + /// Starts 1st Onboarding screen. + static Future startOnboarding(BuildContext context) { + final settings = context.read(); + final strings = context.strings; + final screen = OnboardingAcceptDocumentScreen( + title: strings.privacyPolicyTitle, + url: Uri.parse(strings.privacyPolicyUrl), + step: 1, + versionSetter: (version) async { + settings.acceptedPrivacyPolicyVersion.value = version; + }, + onAccepted: _handlePrivacyPolicyAccepted, + ); + + return _navigateToScreen(context, screen); + } + + static Future _navigateToScreen( + BuildContext context, + Widget screen, [ + bool replace = false, + ]) { + final route = MaterialPageRoute(builder: (_) => screen); + final navigator = Navigator.of(context); + + return replace ? navigator.pushReplacement(route) : navigator.push(route); + } + + static void _handlePrivacyPolicyAccepted(BuildContext context) { + final settings = context.read(); + final strings = context.strings; + final screen = OnboardingAcceptDocumentScreen( + title: strings.termsOfServiceTitle, + url: Uri.parse(strings.termsOfServiceUrl), + step: 2, + versionSetter: (version) async { + settings.acceptedTermsOfServiceVersion.value = version; + }, + onAccepted: _handleTermsOfServiceAccepted, + ); + + _navigateToScreen(context, screen, true); + } + + static void _handleTermsOfServiceAccepted(BuildContext context) { + final settings = context.read(); + final hasSigningCertificate = settings.signingCertificate.value != null; + final Widget screen; + + if (!hasSigningCertificate) { + screen = const OnboardingSelectSigningCertificateScreen( + onCertificateSelected: _handleCertificateSelectionCompleted, + onSkipRequested: _handleCertificateSelectionCompleted, + ); + } else { + screen = const OnboardingFinishedScreen( + onStartRequested: _handleStartRequested, + ); + } + + _navigateToScreen(context, screen, true); + } + + static void _handleCertificateSelectionCompleted(BuildContext context) { + const screen = OnboardingFinishedScreen( + onStartRequested: _handleStartRequested, + ); + + _navigateToScreen(context, screen, true); + } + + static void _handleStartRequested(BuildContext context) { + // TODO Make sure this is always in sync by listening to both values; use TransformValueListenable + refreshOnboardingRequired(context); + + // Notify parent + context.read().add(const RequestOpenFileEvent()); + + // Navigate to root + Navigator.of(context).popUntil((route) { + // Remove until MainScreen + return (route.settings.name == '/'); + }); + } +} diff --git a/lib/ui/screens/about_screen.dart b/lib/ui/screens/about_screen.dart index 6cef83e..29a0f7d 100644 --- a/lib/ui/screens/about_screen.dart +++ b/lib/ui/screens/about_screen.dart @@ -4,12 +4,12 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; import '../../strings_context.dart'; import '../app_theme.dart'; import '../widgets/app_version_text.dart'; -import 'show_terms_of_service_screen.dart'; +import 'show_document_screen.dart'; /// Displays About. /// /// See also: -/// - [ShowTermsOfServiceScreen] +/// - [ShowDocumentScreen] class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); diff --git a/lib/ui/screens/main_menu_screen.dart b/lib/ui/screens/main_menu_screen.dart index 84bd053..6f17d76 100644 --- a/lib/ui/screens/main_menu_screen.dart +++ b/lib/ui/screens/main_menu_screen.dart @@ -5,13 +5,13 @@ import '../../strings_context.dart'; import '../widgets/app_version_text.dart'; import 'about_screen.dart'; import 'settings_screen.dart'; -import 'show_terms_of_service_screen.dart'; +import 'show_document_screen.dart'; import 'start_remote_document_signing_screen.dart'; /// Screen that displays "main menu" with items: /// - link to show [SettingsScreen] /// - link to show [StartRemoteDocumentSigningScreen] -/// - link to show [ShowTermsOfServiceScreen] +/// - link to show Privacy Policy or Terms of Service in [ShowDocumentScreen] /// - link to show [AboutScreen] class MainMenuScreen extends StatelessWidget { const MainMenuScreen({super.key}); @@ -49,6 +49,12 @@ class MainMenuScreen extends StatelessWidget { _showSignRemoteDocument(context); }, ), + _MenuItem( + title: strings.privacyPolicyTitle, + onPressed: () { + _showPrivacyPolicy(context); + }, + ), _MenuItem( title: strings.termsOfServiceTitle, onPressed: () { @@ -94,8 +100,22 @@ class MainMenuScreen extends StatelessWidget { return _openScreen(context, screen); } + static Future _showPrivacyPolicy(BuildContext context) { + final strings = context.strings; + final screen = ShowDocumentScreen( + title: strings.privacyPolicyTitle, + url: Uri.parse(strings.privacyPolicyUrl), + ); + + return _openScreen(context, screen); + } + static Future _showTermsOfService(BuildContext context) { - const screen = ShowTermsOfServiceScreen(); + final strings = context.strings; + final screen = ShowDocumentScreen( + title: strings.termsOfServiceTitle, + url: Uri.parse(strings.termsOfServiceUrl), + ); return _openScreen(context, screen); } diff --git a/lib/ui/screens/main_screen.dart b/lib/ui/screens/main_screen.dart index cab90c1..26c99c4 100644 --- a/lib/ui/screens/main_screen.dart +++ b/lib/ui/screens/main_screen.dart @@ -4,22 +4,22 @@ import 'dart:io' show File; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart' show SvgPicture; import 'package:logging/logging.dart'; -import 'package:provider/provider.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; import '../../app_service.dart'; -import '../../data/settings.dart'; +import '../../bloc/app_bloc.dart'; import '../../deep_links.dart'; import '../../di.dart'; import '../../services/encryption_key_registry.dart'; import '../../strings_context.dart'; import '../app_theme.dart'; +import '../onboarding.dart'; import '../widgets/autogram_logo.dart'; import 'main_menu_screen.dart'; -import 'onboarding_screen.dart'; import 'open_document_screen.dart'; import 'preview_document_screen.dart'; import 'start_remote_document_signing_screen.dart'; @@ -50,6 +50,13 @@ class _MainScreenState extends State { _handleNewIncomingUri(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + Onboarding.refreshOnboardingRequired(context); + } + @override void didUpdateWidget(covariant MainScreen oldWidget) { super.didUpdateWidget(oldWidget); @@ -62,24 +69,27 @@ class _MainScreenState extends State { @override Widget build(BuildContext context) { - final acceptedTermsOfServiceVersion = - context.read().acceptedTermsOfServiceVersion; - - return ValueListenableBuilder( - valueListenable: acceptedTermsOfServiceVersion, - builder: (context, version, _) { - final showQrCodeScannerIcon = version != null; - - return Scaffold( - appBar: _MainAppBar( - context: context, - showQrCodeScannerIcon: showQrCodeScannerIcon, - onMenuPressed: _showMenu, - onQrCodeScannerPressed: _showQrCodeScanner, - ), - body: _Body( - onStartOnboardingRequested: _onStartOnboardingRequested, - onOpenFileRequested: _onOpenFileRequested, + return ValueListenableBuilder( + valueListenable: Onboarding.onboardingRequired, + builder: (context, onboardingRequired, _) { + return BlocListener( + listener: (_, state) { + if (state is RequestOpenFileEvent) { + _onOpenFileRequested(); + } + }, + child: Scaffold( + appBar: _MainAppBar( + context: context, + showQrCodeScannerIcon: (onboardingRequired == false), + onMenuPressed: _showMenu, + onQrCodeScannerPressed: _showQrCodeScanner, + ), + body: _Body( + onboardingRequired: onboardingRequired, + onStartOnboardingRequested: _onStartOnboardingRequested, + onOpenFileRequested: _onOpenFileRequested, + ), ), ); }, @@ -184,19 +194,7 @@ class _MainScreenState extends State { Future _onStartOnboardingRequested() { _logger.fine('Requested to start Onboarding.'); - const screen = OnboardingScreen(); - final route = MaterialPageRoute(builder: (_) => screen); - - return Navigator.of(context).push(route).then((_) { - final arguments = ModalRoute.of(context)?.settings.arguments as Map; - final result = arguments['result']; - - if (result == true) { - // TODO Call this function right when it was pressed on that screen - // Pass via Provider and drop this "arguments" usage - _onOpenFileRequested(); - } - }); + return Onboarding.startOnboarding(context); } Future _onOpenFileRequested() async { @@ -263,10 +261,12 @@ AppBar _MainAppBar({ /// [MainScreen] body. class _Body extends StatelessWidget { + final bool? onboardingRequired; final VoidCallback? onStartOnboardingRequested; final VoidCallback? onOpenFileRequested; const _Body({ + required this.onboardingRequired, required this.onStartOnboardingRequested, required this.onOpenFileRequested, }); @@ -301,35 +301,24 @@ class _Body extends StatelessWidget { } Widget _buildPrimaryButton(BuildContext context) { - // Workaround for preview without ISettings - final listenable = - (context.read()?.acceptedTermsOfServiceVersion ?? - ValueNotifier(1)); - - return ValueListenableBuilder( - valueListenable: listenable, - builder: (context, version, _) { - final termsOfServiceAreAccepted = (version != null); - - VoidCallback? onPressed; - String label; - - if (termsOfServiceAreAccepted) { - onPressed = onOpenFileRequested; - label = context.strings.buttonOpenDocumentLabel; - } else { - onPressed = onStartOnboardingRequested; - label = context.strings.buttonInitialSetupLabel; - } - - return FilledButton( - style: FilledButton.styleFrom( - minimumSize: kPrimaryButtonMinimumSize, - ), - onPressed: onPressed, - child: Text(label), - ); - }); + VoidCallback? onPressed; + String label; + + if (onboardingRequired == false) { + onPressed = onOpenFileRequested; + label = context.strings.buttonOpenDocumentLabel; + } else { + onPressed = onStartOnboardingRequested; + label = context.strings.buttonInitialSetupLabel; + } + + return FilledButton( + style: FilledButton.styleFrom( + minimumSize: kPrimaryButtonMinimumSize, + ), + onPressed: onPressed, + child: Text(label), + ); } } @@ -362,7 +351,13 @@ Widget previewMainAppBar(BuildContext context) { type: MainScreen, ) Widget previewMainScreen(BuildContext context) { + final onboardingRequired = context.knobs.booleanOrNull( + label: "Onboarding is required", + initialValue: false, + ); + return _Body( + onboardingRequired: onboardingRequired, onStartOnboardingRequested: () { developer.log("onStartOnboardingRequested"); }, diff --git a/lib/ui/screens/onboarding_accept_document_screen.dart b/lib/ui/screens/onboarding_accept_document_screen.dart new file mode 100644 index 0000000..455fa5e --- /dev/null +++ b/lib/ui/screens/onboarding_accept_document_screen.dart @@ -0,0 +1,171 @@ +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import '../../di.dart'; +import '../../strings_context.dart'; +import '../../use_case/get_document_version_use_case.dart'; +import '../../util/errors.dart'; +import '../app_theme.dart'; +import '../fragment/show_web_page_fragment.dart'; +import '../onboarding.dart'; +import '../widgets/loading_indicator.dart'; +import '../widgets/step_indicator.dart'; +import 'show_document_screen.dart'; + +/// [Onboarding] screen to accept document - Privacy Policy or Terms of Service. +/// +/// Uses [GetDocumentVersionUseCase]. +/// +/// See also: +/// - [ShowDocumentScreen] +class OnboardingAcceptDocumentScreen extends StatefulWidget { + final String title; + final Uri url; + final int step; + final AsyncValueSetter versionSetter; + final void Function(BuildContext context) onAccepted; + + const OnboardingAcceptDocumentScreen({ + super.key, + required this.title, + required this.url, + required this.step, + required this.versionSetter, + required this.onAccepted, + }); + + @override + State createState() => + _OnboardingAcceptDocumentScreenState(); +} + +class _OnboardingAcceptDocumentScreenState + extends State { + bool documentLoaded = false; + bool documentVersionIsLoading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: Text(widget.title), + ), + body: SafeArea( + child: _getBody(), + ), + ); + } + + Widget _getBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ShowWebPageFragment( + url: widget.url, + onUrlLoaded: () { + if (mounted) { + setState(() { + documentLoaded = true; + }); + } + }, + ), + ), + + // Steps + Padding( + padding: const EdgeInsets.only(top: 8), + child: StepIndicator(stepNumber: widget.step, totalSteps: 3), + ), + + // Primary button + Padding( + padding: kScreenMargin, + child: FilledButton( + style: FilledButton.styleFrom( + minimumSize: kPrimaryButtonMinimumSize, + ), + onPressed: + documentLoaded && !documentVersionIsLoading ? _onAccept : null, + child: !documentLoaded || documentVersionIsLoading + ? const LoadingIndicator() + : Text(context.strings.buttonAgreeLabel), + ), + ), + ], + ); + } + + void _onAccept() async { + final getDocumentVersion = getIt.get(); + + try { + setState(() { + documentVersionIsLoading = true; + }); + + final documentVersion = await getDocumentVersion(widget.url); + + await widget.versionSetter(documentVersion); + + if (mounted) { + widget.onAccepted.call(context); + } + } catch (error) { + _showError(error); + + setState(() { + documentVersionIsLoading = false; + }); + } + } + + void _showError(Object error) { + final snackBar = SnackBar( + content: Text(getErrorMessage(error)), + ); + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(snackBar); + } +} + +@widgetbook.UseCase( + path: '[Screens]', + name: '', + type: OnboardingAcceptDocumentScreen, +) +Widget previewOnboardingAcceptDocumentScreen(BuildContext context) { + getIt.registerLazySingleton( + () => GetDocumentVersionUseCase(), + ); + + final strings = context.strings; + final title = context.knobs.string( + label: "Title", + initialValue: strings.privacyPolicyTitle, + ); + final url = context.knobs.string( + label: "URL", + initialValue: strings.privacyPolicyUrl, + ); + + return OnboardingAcceptDocumentScreen( + title: title, + url: Uri.parse(url), + step: 1, + versionSetter: (version) async { + developer.log("$version accepted"); + }, + onAccepted: (_) { + developer.log("onAccepted"); + }, + ); +} diff --git a/lib/ui/screens/onboarding_accept_terms_of_service_screen.dart b/lib/ui/screens/onboarding_accept_terms_of_service_screen.dart deleted file mode 100644 index 12361e7..0000000 --- a/lib/ui/screens/onboarding_accept_terms_of_service_screen.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:developer' as developer; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; - -import '../../data/settings.dart'; -import '../../strings_context.dart'; -import '../app_theme.dart'; -import '../fragment/show_web_page_fragment.dart'; -import '../widgets/step_indicator.dart'; -import 'onboarding_screen.dart'; - -/// [OnboardingScreen] to accept Terms of Service document. -/// -/// Saves version into [ISettings.acceptedTermsOfServiceVersion]. -/// -/// Consumes [ISettings]. -class OnboardingAcceptTermsOfServiceScreen extends StatefulWidget { - final VoidCallback onCanceled; - final VoidCallback onTermsOfServiceAccepted; - - const OnboardingAcceptTermsOfServiceScreen({ - super.key, - required this.onCanceled, - required this.onTermsOfServiceAccepted, - }); - - @override - State createState() => - _OnboardingAcceptTermsOfServiceScreenState(); -} - -class _OnboardingAcceptTermsOfServiceScreenState - extends State { - final url = Uri.parse("https://slovensko.digital/o-nas/stanovy/"); - - bool documentLoaded = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - // Need to add BackButton explicitly because it's indie nested Navigator - leading: BackButton( - onPressed: widget.onCanceled, - ), - title: Text(context.strings.termsOfServiceTitle), - ), - body: SafeArea( - child: _getBody(context), - ), - ); - } - - Widget _getBody(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: ShowWebPageFragment( - url: url, - onUrlLoaded: () { - if (mounted) { - setState(() { - documentLoaded = true; - }); - } - }, - ), - ), - - // Steps - const Padding( - padding: EdgeInsets.only(top: 8), - child: StepIndicator(stepNumber: 1, totalSteps: 3), - ), - - // Primary button - Padding( - padding: kScreenMargin, - child: FilledButton( - style: FilledButton.styleFrom( - minimumSize: kPrimaryButtonMinimumSize, - ), - onPressed: documentLoaded ? _onAccept : null, - child: Text(context.strings.buttonAgreeLabel), - ), - ), - ], - ); - } - - void _onAccept() { - // TODO Get current ToS document version - const version = 1; - - context.read()?.acceptedTermsOfServiceVersion.value = version; - widget.onTermsOfServiceAccepted(); - } -} - -@widgetbook.UseCase( - path: '[Screens]', - name: 'OnboardingAcceptTermsOfServiceScreen', - type: OnboardingAcceptTermsOfServiceScreen, -) -Widget previewOnboardingAcceptTermsOfServiceScreen(BuildContext context) { - return OnboardingAcceptTermsOfServiceScreen( - onCanceled: () { - developer.log("onTermsOfServiceAccepted"); - }, - onTermsOfServiceAccepted: () { - developer.log("onTermsOfServiceAccepted"); - }, - ); -} diff --git a/lib/ui/screens/onboarding_finished_screen.dart b/lib/ui/screens/onboarding_finished_screen.dart index 2f32372..6dcd1f7 100644 --- a/lib/ui/screens/onboarding_finished_screen.dart +++ b/lib/ui/screens/onboarding_finished_screen.dart @@ -5,16 +5,15 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; import '../../strings_context.dart'; import '../app_theme.dart'; +import '../onboarding.dart'; import '../widgets/result_view.dart'; -import 'main_screen.dart'; -import 'onboarding_screen.dart'; -/// [OnboardingScreen] to display some information when finished. +/// [Onboarding] screen to display some information when finished. /// /// See also: /// - [MainScreen] class OnboardingFinishedScreen extends StatelessWidget { - final VoidCallback? onStartRequested; + final ValueSetter? onStartRequested; const OnboardingFinishedScreen({ super.key, @@ -38,7 +37,11 @@ class OnboardingFinishedScreen extends StatelessWidget { style: FilledButton.styleFrom( minimumSize: kPrimaryButtonMinimumSize, ), - onPressed: onStartRequested, + onPressed: onStartRequested != null + ? () { + onStartRequested?.call(context); + } + : null, child: Text(context.strings.buttonOpenDocumentLabel), ), ], @@ -60,12 +63,12 @@ class OnboardingFinishedScreen extends StatelessWidget { @widgetbook.UseCase( path: '[Screens]', - name: 'OnboardingFinishedScreen', + name: '', type: OnboardingFinishedScreen, ) Widget previewOnboardingFinishedScreen(BuildContext context) { return OnboardingFinishedScreen( - onStartRequested: () { + onStartRequested: (_) { developer.log("onStartRequested"); }, ); diff --git a/lib/ui/screens/onboarding_screen.dart b/lib/ui/screens/onboarding_screen.dart deleted file mode 100644 index 767f640..0000000 --- a/lib/ui/screens/onboarding_screen.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../data/settings.dart'; -import 'onboarding_accept_terms_of_service_screen.dart'; -import 'onboarding_finished_screen.dart'; -import 'onboarding_select_signing_certificate_screen.dart'; - -/// Screen for whole onboarding flow. -/// Uses its own [Navigator]. -/// -/// Flow based on steps from [_OnboardingStep] values: -/// 1. Accept Terms of Service - [OnboardingAcceptTermsOfServiceScreen] -/// 2. Select Signing Certificate - [OnboardingSelectSigningCertificateScreen] -/// 3. Success - [OnboardingFinishedScreen] -class OnboardingScreen extends StatefulWidget { - const OnboardingScreen({super.key}); - - @override - State createState() => _OnboardingScreenState(); -} - -class _OnboardingScreenState extends State { - final GlobalKey navigatorKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - // Need child Navigator so we can navigate between steps - return Navigator( - key: navigatorKey, - initialRoute: _OnboardingStep.values.first.name, - onGenerateRoute: _generateRoute, - ); - } - - Route? _generateRoute(RouteSettings settings) { - final route = settings.name!; - final step = _OnboardingStep.values.byName(route); - final Widget child = switch (step) { - _OnboardingStep.acceptTermsOfService => - OnboardingAcceptTermsOfServiceScreen( - onCanceled: () { - Navigator.of(context).maybePop(); - }, - onTermsOfServiceAccepted: _handleTermsOfServiceAccepted, - ), - _OnboardingStep.selectSigningCertificate => - OnboardingSelectSigningCertificateScreen( - onCertificateSelected: _handleCertificateSelected, - onSkipRequested: _handleCertificateSelectionSkipped, - ), - _OnboardingStep.showSummary => OnboardingFinishedScreen( - onStartRequested: _handleStartRequested, - ), - }; - - return MaterialPageRoute( - builder: (context) => child, - ); - } - - void _navigateToStep(_OnboardingStep step) { - navigatorKey.currentState?.pushNamed(step.name); - } - - void _handleTermsOfServiceAccepted() { - final settings = context.read(); - final hasSigningCertificate = settings.signingCertificate.value != null; - final nextStep = hasSigningCertificate - ? _OnboardingStep.showSummary - : _OnboardingStep.selectSigningCertificate; - - _navigateToStep(nextStep); - } - - void _handleCertificateSelectionSkipped() { - _navigateToStep(_OnboardingStep.showSummary); - } - - void _handleCertificateSelected() { - _navigateToStep(_OnboardingStep.showSummary); - } - - void _handleStartRequested() { - Navigator.of(context).popUntil((route) { - // Remove until MainScreen - if (route.settings.name == '/') { - // This "result" will be read - (route.settings.arguments as Map)["result"] = true; - return true; - } - - return false; - }); - } -} - -/// Step for [OnboardingScreen]. -enum _OnboardingStep { - acceptTermsOfService, - selectSigningCertificate, - showSummary, -} diff --git a/lib/ui/screens/onboarding_select_signing_certificate_screen.dart b/lib/ui/screens/onboarding_select_signing_certificate_screen.dart index 51c0743..6033cf1 100644 --- a/lib/ui/screens/onboarding_select_signing_certificate_screen.dart +++ b/lib/ui/screens/onboarding_select_signing_certificate_screen.dart @@ -9,12 +9,12 @@ import '../../data/settings.dart'; import '../../strings_context.dart'; import '../app_theme.dart'; import '../fragment/select_signing_certificate_fragment.dart'; +import '../onboarding.dart'; import '../widgets/certificate_picker.dart'; import '../widgets/loading_indicator.dart'; import '../widgets/step_indicator.dart'; -import 'onboarding_screen.dart'; -/// [OnboardingScreen] to select and save signing certificate into +/// [Onboarding] screen to select and save signing certificate into /// [ISettings.signingCertificate]. /// This screen can be skipped. /// @@ -22,8 +22,8 @@ import 'onboarding_screen.dart'; /// /// Consumes [ISettings]. class OnboardingSelectSigningCertificateScreen extends StatelessWidget { - final VoidCallback onCertificateSelected; - final VoidCallback? onSkipRequested; + final ValueSetter onCertificateSelected; + final ValueSetter? onSkipRequested; const OnboardingSelectSigningCertificateScreen({ super.key, @@ -33,13 +33,12 @@ class OnboardingSelectSigningCertificateScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - child: Scaffold( - appBar: AppBar( - leading: const BackButton(), - title: Text(context.strings.selectSigningCertificateTitle), - ), - body: _buildBody(context), + return Scaffold( + appBar: AppBar( + title: Text(context.strings.selectSigningCertificateTitle), + ), + body: SafeArea( + child: _buildBody(context), ), ); } @@ -75,7 +74,11 @@ class OnboardingSelectSigningCertificateScreen extends StatelessWidget { onCertificateSelected: (certificate) { _handleCertificateSelected(context, certificate); }, - onSkipRequested: onSkipRequested, + onSkipRequested: onSkipRequested != null + ? () { + onSkipRequested?.call(context); + } + : null, ); } @@ -84,7 +87,7 @@ class OnboardingSelectSigningCertificateScreen extends StatelessWidget { Certificate certificate, ) { context.read().signingCertificate.value = certificate; - onCertificateSelected.call(); + onCertificateSelected.call(context); } } @@ -139,7 +142,7 @@ class _BodyState extends State<_Body> { // Steps const Padding( padding: EdgeInsets.only(top: 8, bottom: 16), - child: StepIndicator(stepNumber: 2, totalSteps: 3), + child: StepIndicator(stepNumber: 3, totalSteps: 3), ), // Primary button diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 6a5620d..90fc8ea 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -11,6 +11,7 @@ import '../../data/signature_type.dart'; import '../../oids.dart'; import '../../strings_context.dart'; import '../app_theme.dart'; +import '../onboarding.dart'; import '../widgets/option_picker.dart'; import '../widgets/preference_tile.dart'; import 'paired_device_list_screen.dart'; @@ -19,7 +20,9 @@ import 'paired_device_list_screen.dart'; /// /// Contains: /// - editor for [ISettings.signingPdfContainer] -/// - editor for [ISettings.signingCertificate] +/// - display for [ISettings.signingCertificate] +/// - editor for [ISettings.signatureType] +/// - navigate to [PairedDeviceListScreen] class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -64,6 +67,7 @@ class _Body extends StatelessWidget { onPressed: () async { await settings.clear(); if (context.mounted) { + Onboarding.refreshOnboardingRequired(context); await Navigator.of(context).maybePop(); } }, @@ -221,7 +225,10 @@ class _ValueListenableBoundTile extends StatelessWidget { }); } }, - labelBuilder: (T value) => Text(summaryGetter(value) ?? ''), + labelBuilder: (T value) => Text( + summaryGetter(value) ?? '', + maxLines: 2, + ), ), ); }, @@ -257,7 +264,11 @@ Widget previewSettingsScreen(BuildContext context) { /// Mock [ISettings] impl. for preview. class _MockSettings implements ISettings { @override - late final ValueNotifier acceptedTermsOfServiceVersion = + late final ValueNotifier acceptedPrivacyPolicyVersion = + ValueNotifier(null); + + @override + late final ValueNotifier acceptedTermsOfServiceVersion = ValueNotifier(null); @override @@ -275,6 +286,7 @@ class _MockSettings implements ISettings { @override Future clear() { final props = [ + acceptedPrivacyPolicyVersion, acceptedTermsOfServiceVersion, signingPdfContainer, signatureType, diff --git a/lib/ui/screens/show_document_screen.dart b/lib/ui/screens/show_document_screen.dart new file mode 100644 index 0000000..04fc3aa --- /dev/null +++ b/lib/ui/screens/show_document_screen.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import '../../strings_context.dart'; +import '../fragment/show_web_page_fragment.dart'; +import 'about_screen.dart'; +import 'onboarding_accept_document_screen.dart'; + +/// Screen to display some HTML document using [ShowWebPageFragment]. +/// +/// See also: +/// - [AboutScreen] +/// - [OnboardingAcceptDocumentScreen] +class ShowDocumentScreen extends StatelessWidget { + final String title; + final Uri url; + + const ShowDocumentScreen({ + super.key, + required this.title, + required this.url, + }); + + @override + Widget build(BuildContext context) { + final body = ShowWebPageFragment( + url: url, + ); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(title), + actions: const [CloseButton()], + ), + body: body, + ); + } +} + +@widgetbook.UseCase( + path: '[Screens]', + name: '', + type: ShowDocumentScreen, +) +Widget previewShowDocumentScreen(BuildContext context) { + final strings = context.strings; + final title = context.knobs.string( + label: "Title", + initialValue: strings.privacyPolicyTitle, + ); + final url = context.knobs.string( + label: "URL", + initialValue: strings.privacyPolicyUrl, + ); + + return ShowDocumentScreen( + title: title, + url: Uri.parse(url), + ); +} diff --git a/lib/ui/screens/show_terms_of_service_screen.dart b/lib/ui/screens/show_terms_of_service_screen.dart deleted file mode 100644 index d985a18..0000000 --- a/lib/ui/screens/show_terms_of_service_screen.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; - -import '../../strings_context.dart'; -import '../fragment/show_web_page_fragment.dart'; -import 'about_screen.dart'; - -/// Screen to display only Terms of Service (ToS). -/// -/// See also: -/// - [AboutScreen] -class ShowTermsOfServiceScreen extends StatelessWidget { - static final url = Uri.parse("https://slovensko.digital/o-nas/stanovy/"); - - const ShowTermsOfServiceScreen({super.key}); - - @override - Widget build(BuildContext context) { - final body = ShowWebPageFragment( - url: url, - ); - - return Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - title: Text(context.strings.termsOfServiceTitle), - actions: const [CloseButton()], - ), - body: body, - ); - } -} - -@widgetbook.UseCase( - path: '[Screens]', - name: 'ShowTermsOfServiceScreen', - type: ShowTermsOfServiceScreen, -) -Widget previewShowTermsOfServiceScreen(BuildContext context) { - return const ShowTermsOfServiceScreen(); -} diff --git a/lib/ui/widgets/option_picker.dart b/lib/ui/widgets/option_picker.dart index ded891d..4a20cd0 100644 --- a/lib/ui/widgets/option_picker.dart +++ b/lib/ui/widgets/option_picker.dart @@ -44,7 +44,7 @@ class OptionPicker extends StatelessWidget { Widget _listItem(T value) { final labelBuilder = this.labelBuilder ?? _defaultLabelBuilder; - final label = labelBuilder(value); + final label = Expanded(child: labelBuilder(value)); final radio = Transform.scale( scale: kRadioScale, child: Radio( @@ -64,7 +64,7 @@ class OptionPicker extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ radio, - const SizedBox(width: 16), + const SizedBox(width: 8), label, ], ), @@ -75,12 +75,10 @@ class OptionPicker extends StatelessWidget { Widget _defaultLabelBuilder(T value) { final text = (value is Enum ? value.name : value.toString()); - return Expanded( - child: Text( - text, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + return Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, ); } } diff --git a/lib/use_case/get_document_version_use_case.dart b/lib/use_case/get_document_version_use_case.dart new file mode 100644 index 0000000..63838c7 --- /dev/null +++ b/lib/use_case/get_document_version_use_case.dart @@ -0,0 +1,59 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:html/parser.dart' as html show parse; +import 'package:http/http.dart' as http; +import 'package:injectable/injectable.dart'; + +/// Gets the document - Privacy Policy or Terms of Service - version. +/// +/// Impl. reads value from "version" `meta` tag. +@lazySingleton +class GetDocumentVersionUseCase { + static final http.Client _client = http.Client(); + + /// Gets the document version on this [url]. + Future call(Uri url) async { + final text = await _getHtml(url); + + if (text.isEmpty) { + throw ArgumentError.value( + url, "url", "GET on URL yields no text content."); + } + + final document = html.parse(text); + final meta = + document.querySelector('html > head > meta[name="version"][content]'); + final version = (meta?.attributes["content"]?.trim() ?? ''); + + if (version.isEmpty) { + throw ArgumentError.value( + url, "url", "Required meta tag is not present."); + } + + return version; + } + + /// Gets the HTML content from given [url]. + static Future _getHtml(Uri url) async { + final response = await _client.get(url); + + return response.body; + } + + /// Parse "version" value from given [text] HTML. + @visibleForTesting + static String parseVersion(String text) { + final document = html.parse(text); + final meta = + document.querySelector('html > head > meta[name="version"][content]'); + final version = (meta?.attributes["content"]?.trim() ?? ''); + + if (version.isEmpty) { + throw ArgumentError.value(text.substring(0, min(text.length, 50)), "text", + "Required meta tag is not present."); + } + + return version; + } +} diff --git a/lib/widgetbook_app.directories.g.dart b/lib/widgetbook_app.directories.g.dart index 2764edb..4b83093 100644 --- a/lib/widgetbook_app.directories.g.dart +++ b/lib/widgetbook_app.directories.g.dart @@ -15,7 +15,7 @@ import 'package:autogram/ui/fragment/show_web_page_fragment.dart' as _i21; import 'package:autogram/ui/screens/about_screen.dart' as _i24; import 'package:autogram/ui/screens/main_menu_screen.dart' as _i25; import 'package:autogram/ui/screens/main_screen.dart' as _i2; -import 'package:autogram/ui/screens/onboarding_accept_terms_of_service_screen.dart' +import 'package:autogram/ui/screens/onboarding_accept_document_screen.dart' as _i26; import 'package:autogram/ui/screens/onboarding_finished_screen.dart' as _i27; import 'package:autogram/ui/screens/onboarding_select_signing_certificate_screen.dart' @@ -28,7 +28,7 @@ import 'package:autogram/ui/screens/preview_document_screen.dart' as _i32; import 'package:autogram/ui/screens/qr_code_scanner_screen.dart' as _i19; import 'package:autogram/ui/screens/select_certificate_screen.dart' as _i33; import 'package:autogram/ui/screens/settings_screen.dart' as _i34; -import 'package:autogram/ui/screens/show_terms_of_service_screen.dart' as _i35; +import 'package:autogram/ui/screens/show_document_screen.dart' as _i35; import 'package:autogram/ui/screens/sign_document_screen.dart' as _i36; import 'package:autogram/ui/screens/start_remote_document_signing_screen.dart' as _i37; @@ -309,16 +309,16 @@ final directories = <_i1.WidgetbookNode>[ ), ), _i1.WidgetbookLeafComponent( - name: 'OnboardingAcceptTermsOfServiceScreen', + name: 'OnboardingAcceptDocumentScreen', useCase: _i1.WidgetbookUseCase( - name: 'OnboardingAcceptTermsOfServiceScreen', - builder: _i26.previewOnboardingAcceptTermsOfServiceScreen, + name: '', + builder: _i26.previewOnboardingAcceptDocumentScreen, ), ), _i1.WidgetbookLeafComponent( name: 'OnboardingFinishedScreen', useCase: _i1.WidgetbookUseCase( - name: 'OnboardingFinishedScreen', + name: '', builder: _i27.previewOnboardingFinishedScreen, ), ), @@ -442,10 +442,10 @@ final directories = <_i1.WidgetbookNode>[ ), ), _i1.WidgetbookLeafComponent( - name: 'ShowTermsOfServiceScreen', + name: 'ShowDocumentScreen', useCase: _i1.WidgetbookUseCase( - name: 'ShowTermsOfServiceScreen', - builder: _i35.previewShowTermsOfServiceScreen, + name: '', + builder: _i35.previewShowDocumentScreen, ), ), _i1.WidgetbookComponent( diff --git a/pubspec.lock b/pubspec.lock index 9e7d126..fd31bbd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: archive - sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265 + sha256: "6bd38d335f0954f5fad9c79e614604fbf03a0e5b975923dd001b6ea965ef5b4b" url: "https://pub.dev" source: hosted - version: "3.5.1" + version: "3.6.0" args: dependency: transitive description: @@ -248,6 +248,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + csslib: + dependency: transitive + description: + name: csslib + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dart_style: dependency: transitive description: @@ -475,8 +483,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + html: + dependency: "direct main" + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba @@ -503,10 +519,10 @@ packages: dependency: transitive description: name: image - sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" url: "https://pub.dev" source: hosted - version: "4.1.7" + version: "4.2.0" injectable: dependency: "direct main" description: @@ -791,10 +807,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.9.1" pool: dependency: transitive description: @@ -847,10 +863,10 @@ packages: dependency: transitive description: name: qs_dart - sha256: fbf5661894b27c439d5361ce4aaf00d10f90bce21722e749e9f7c7e2ae08c2ca + sha256: "5f1827ccdfa061582c121e7a8fe4a83319fa455bcd1fd6e46ff5b17b57aed680" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" recase: dependency: transitive description: @@ -1124,10 +1140,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" + sha256: "17cd5e205ea615e2c6ea7a77323a11712dffa0720a8a90540db57a01347f9ad9" url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_ios: dependency: transitive description: @@ -1148,10 +1164,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" url_launcher_platform_interface: dependency: transitive description: @@ -1292,10 +1308,10 @@ packages: dependency: transitive description: name: webview_flutter_android - sha256: dad3313c9ead95517bb1cae5e1c9d20ba83729d5a59e5e83c0a2d66203f27f91 + sha256: "2282ba2320af34b2bd5320156c664d73f3f022341ed78847bc87723bf88c142f" url: "https://pub.dev" source: hosted - version: "3.16.1" + version: "3.16.2" webview_flutter_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 76e4bcf..3525c3f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,8 @@ dependencies: path: ../autogram_sign flutter_svg: package_info_plus: ^5.0.1 - chopper: + http: + chopper: '>=7.4.0 <8.0.0' injectable: ^2.2.0 get_it: ^7.6.1 flutter_hooks: ^0.20.5 @@ -44,6 +45,7 @@ dependencies: firebase_crashlytics: url_launcher: mobile_scanner: + html: dev_dependencies: flutter_test: diff --git a/test/foundation/transform_value_listenable_test.dart b/test/foundation/transform_value_listenable_test.dart new file mode 100644 index 0000000..a3c182a --- /dev/null +++ b/test/foundation/transform_value_listenable_test.dart @@ -0,0 +1,47 @@ +import 'package:autogram/foundation/transform_value_listenable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:test/test.dart'; + +/// Tests for the [TransformValueListenable] class. +void main() { + group('TransformValueListenable', () { + test('TransformValueListenable has initial value from two values', () { + final vl1 = ValueNotifier(1); + final vl2 = ValueNotifier(2); + final tv = TransformValueListenable( + listenable1: vl1, + listenable2: vl2, + transformation: (value1, value2) => value1 + value2, + ); + + expect(tv.listenable.value, 1 + 2); + }); + + test('TransformValueListenable notifies regarding new value', () { + final vl1 = ValueNotifier(1); + final vl2 = ValueNotifier(2); + final tv = TransformValueListenable( + listenable1: vl1, + listenable2: vl2, + transformation: (value1, value2) => value1 + value2, + ); + List changes = []; + tv.listenable.addListener(() { + changes.add(tv.listenable.value); + }); + + expect(tv.listenable.value, 3); + expect(changes, isEmpty); + + vl1.value = 10; + + expect(tv.listenable.value, 10 + 2); + expect(changes, [10 + 2]); + + vl2.value = 5; + + expect(tv.listenable.value, 10 + 5); + expect(changes, [10 + 2, 10 + 5]); + }); + }); +} diff --git a/test/use_case/get_document_version_use_case_test.dart b/test/use_case/get_document_version_use_case_test.dart new file mode 100644 index 0000000..07f406b --- /dev/null +++ b/test/use_case/get_document_version_use_case_test.dart @@ -0,0 +1,96 @@ +import 'package:autogram/use_case/get_document_version_use_case.dart'; +import 'package:test/test.dart'; + +/// Tests for the [GetDocumentVersionUseCase] class. +void main() { + group('GetDocumentVersionUseCase', () { + test('parseVersion throws ArgumentError for empty HTML', () { + expect( + () => GetDocumentVersionUseCase.parseVersion(""), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + + expect( + () => GetDocumentVersionUseCase.parseVersion(''), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + }); + + test( + 'parseVersion throws ArgumentError for HTML with missing or invalid meta', + () { + expect( + () => GetDocumentVersionUseCase.parseVersion( + '</head></html>', + ), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + + expect( + () => GetDocumentVersionUseCase.parseVersion(""" + <html lang="en"> + <head> + <title /> + <meta content="utf-8" http-equiv="encoding"> + </head> + </html> + """), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + expect( + () => GetDocumentVersionUseCase.parseVersion(""" + <html lang="en"> + <head> + <title /> + <meta name="version"> + </head> + </html> + """), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + expect( + () => GetDocumentVersionUseCase.parseVersion(""" + <html lang="en"> + <head> + <title /> + <meta name="version" content=""> + </head> + </html> + """), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + }); + + test('parseVersion returns value for valid meta with value', () { + expect( + () => GetDocumentVersionUseCase.parseVersion( + '<html lang="en"><head><title>', + ), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + + expect( + () => GetDocumentVersionUseCase.parseVersion(""" + + + + + + + """), + throwsA(predicate((e) => e is ArgumentError && e.name == 'text')), + ); + expect( + GetDocumentVersionUseCase.parseVersion(""" + + + + + + + """), + equals("2024-05-20T12:43:33Z"), + ); + }); + }); +}