From 50348cd8d83ec0386d9805ea5ee0c7472c20b4e0 Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:16:14 +0200 Subject: [PATCH] feat(cat-voices): password strength indicator (#861) * feat(cat-voices): password strength calculator * feat(cat-voices): add password strength indicator widget * chore(cat-voices): reformat --- .../voices_password_strength_indicator.dart | 116 ++++++++++++++++++ catalyst_voices/lib/widgets/widgets.dart | 1 + .../catalyst_voices_localizations.dart | 18 +++ .../catalyst_voices_localizations_en.dart | 9 ++ .../catalyst_voices_localizations_es.dart | 9 ++ .../lib/l10n/intl_en.arb | 12 ++ .../src/{ => auth}/authentication_status.dart | 0 .../lib/src/auth/password_strength.dart | 27 ++++ .../lib/src/catalyst_voices_models.dart | 3 +- .../catalyst_voices_models/pubspec.yaml | 4 +- .../test/auth/password_strength_test.dart | 55 +++++++++ .../examples/voices_indicators_example.dart | 52 +++++--- melos.yaml | 1 + 13 files changed, 285 insertions(+), 22 deletions(-) create mode 100644 catalyst_voices/lib/widgets/indicators/voices_password_strength_indicator.dart rename catalyst_voices/packages/catalyst_voices_models/lib/src/{ => auth}/authentication_status.dart (100%) create mode 100644 catalyst_voices/packages/catalyst_voices_models/lib/src/auth/password_strength.dart create mode 100644 catalyst_voices/packages/catalyst_voices_models/test/auth/password_strength_test.dart diff --git a/catalyst_voices/lib/widgets/indicators/voices_password_strength_indicator.dart b/catalyst_voices/lib/widgets/indicators/voices_password_strength_indicator.dart new file mode 100644 index 0000000000..68333b325d --- /dev/null +++ b/catalyst_voices/lib/widgets/indicators/voices_password_strength_indicator.dart @@ -0,0 +1,116 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +/// An indicator for a [PasswordStrength]. +/// +/// Fills in all the available horizontal space, +/// use a [SizedBox] to limit it's width. +final class VoicesPasswordStrengthIndicator extends StatelessWidget { + final PasswordStrength passwordStrength; + + const VoicesPasswordStrengthIndicator({ + super.key, + required this.passwordStrength, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Label(passwordStrength: passwordStrength), + const SizedBox(height: 16), + _Indicator(passwordStrength: passwordStrength), + ], + ); + } +} + +class _Label extends StatelessWidget { + final PasswordStrength passwordStrength; + + const _Label({required this.passwordStrength}); + + @override + Widget build(BuildContext context) { + return Text( + switch (passwordStrength) { + PasswordStrength.weak => context.l10n.weakPasswordStrength, + PasswordStrength.normal => context.l10n.normalPasswordStrength, + PasswordStrength.strong => context.l10n.goodPasswordStrength, + }, + style: Theme.of(context).textTheme.bodySmall, + ); + } +} + +class _Indicator extends StatelessWidget { + static const double _backgroundTrackHeight = 4; + static const double _foregroundTrackHeight = 6; + static const double _tracksGap = 8; + + final PasswordStrength passwordStrength; + + const _Indicator({required this.passwordStrength}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _foregroundTrackHeight, + child: LayoutBuilder( + builder: (context, constraints) { + final totalWidthOfAllGaps = + (PasswordStrength.values.length - 1) * _tracksGap; + final availableWidth = constraints.maxWidth - totalWidthOfAllGaps; + final trackWidth = availableWidth / PasswordStrength.values.length; + + return Stack( + children: [ + Positioned.fill( + top: 1, + child: Container( + height: _backgroundTrackHeight, + decoration: BoxDecoration( + color: Theme.of(context).colors.onSurfaceSecondary08, + borderRadius: BorderRadius.circular(_backgroundTrackHeight), + ), + ), + ), + for (final strength in PasswordStrength.values) + if (passwordStrength.index >= strength.index) + Positioned( + left: strength.index * (trackWidth + _tracksGap), + width: trackWidth, + child: _Track(passwordStrength: strength), + ), + ], + ); + }, + ), + ); + } +} + +class _Track extends StatelessWidget { + final PasswordStrength passwordStrength; + + const _Track({required this.passwordStrength}); + + @override + Widget build(BuildContext context) { + return Container( + height: _Indicator._foregroundTrackHeight, + decoration: BoxDecoration( + color: switch (passwordStrength) { + PasswordStrength.weak => Theme.of(context).colorScheme.error, + PasswordStrength.normal => Theme.of(context).colors.warning, + PasswordStrength.strong => Theme.of(context).colors.success, + }, + borderRadius: BorderRadius.circular(_Indicator._foregroundTrackHeight), + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 0b9e550ea8..ff32170b6e 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -34,6 +34,7 @@ export 'indicators/process_progress_indicator.dart'; export 'indicators/voices_circular_progress_indicator.dart'; export 'indicators/voices_linear_progress_indicator.dart'; export 'indicators/voices_no_internet_connection_banner.dart'; +export 'indicators/voices_password_strength_indicator.dart'; export 'indicators/voices_status_indicator.dart'; export 'menu/voices_list_tile.dart'; export 'menu/voices_menu.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart index 2ae03bd9b8..1105d7c665 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart @@ -645,6 +645,24 @@ abstract class VoicesLocalizations { /// In en, this message translates to: /// **'Your internet is playing hide and seek. Check your internet connection, or try again in a moment.'** String get noConnectionBannerDescription; + + /// Describes a password that is weak + /// + /// In en, this message translates to: + /// **'Weak password strength'** + String get weakPasswordStrength; + + /// Describes a password that has medium strength. + /// + /// In en, this message translates to: + /// **'Normal password strength'** + String get normalPasswordStrength; + + /// Describes a password that is strong. + /// + /// In en, this message translates to: + /// **'Good password strength'** + String get goodPasswordStrength; } class _VoicesLocalizationsDelegate extends LocalizationsDelegate { diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart index 967640ee62..b4ae93ae36 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart @@ -336,4 +336,13 @@ class VoicesLocalizationsEn extends VoicesLocalizations { @override String get noConnectionBannerDescription => 'Your internet is playing hide and seek. Check your internet connection, or try again in a moment.'; + + @override + String get weakPasswordStrength => 'Weak password strength'; + + @override + String get normalPasswordStrength => 'Normal password strength'; + + @override + String get goodPasswordStrength => 'Good password strength'; } diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart index 33e8773d20..734ef69842 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart @@ -336,4 +336,13 @@ class VoicesLocalizationsEs extends VoicesLocalizations { @override String get noConnectionBannerDescription => 'Your internet is playing hide and seek. Check your internet connection, or try again in a moment.'; + + @override + String get weakPasswordStrength => 'Weak password strength'; + + @override + String get normalPasswordStrength => 'Normal password strength'; + + @override + String get goodPasswordStrength => 'Good password strength'; } diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb index 215e697aaf..65c3750b5b 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -413,5 +413,17 @@ "noConnectionBannerDescription": "Your internet is playing hide and seek. Check your internet connection, or try again in a moment.", "@noConnectionBannerDescription": { "description": "Text shown in the No Internet Connection Banner widget for the description below the title." + }, + "weakPasswordStrength": "Weak password strength", + "@weakPasswordStrength": { + "description": "Describes a password that is weak" + }, + "normalPasswordStrength": "Normal password strength", + "@normalPasswordStrength": { + "description": "Describes a password that has medium strength." + }, + "goodPasswordStrength": "Good password strength", + "@goodPasswordStrength": { + "description": "Describes a password that is strong." } } \ No newline at end of file diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/authentication_status.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/auth/authentication_status.dart similarity index 100% rename from catalyst_voices/packages/catalyst_voices_models/lib/src/authentication_status.dart rename to catalyst_voices/packages/catalyst_voices_models/lib/src/auth/authentication_status.dart diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/auth/password_strength.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/auth/password_strength.dart new file mode 100644 index 0000000000..5f5c1205b3 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/auth/password_strength.dart @@ -0,0 +1,27 @@ +import 'package:password_strength/password_strength.dart' as ps; + +/// Describes strength of a password. +/// +/// The enum values must be sorted from the weakest to the strongest. +enum PasswordStrength { + /// A weak password. Simple, already exposed, commonly used, etc. + weak, + + /// A medium password, not complex. + normal, + + /// A complex password with characters from different groups. + strong; + + /// The minimum length of accepted password. + static const int minimumPasswordLength = 8; + + factory PasswordStrength.calculate(String text) { + if (text.length < minimumPasswordLength) return PasswordStrength.weak; + + final strength = ps.estimatePasswordStrength(text); + if (strength <= 0.33) return PasswordStrength.weak; + if (strength <= 0.66) return PasswordStrength.normal; + return PasswordStrength.strong; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 26b783cd49..072d1267fe 100644 --- a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -1,6 +1,7 @@ library catalyst_voices_models; -export 'authentication_status.dart'; +export 'auth/authentication_status.dart'; +export 'auth/password_strength.dart'; export 'errors/errors.dart'; export 'proposal/funded_proposal.dart'; export 'proposal/pending_proposal.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml index df456f50f1..a25c85724b 100644 --- a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml @@ -11,7 +11,9 @@ dependencies: equatable: ^2.0.5 flutter_quill: ^10.5.13 meta: ^1.10.0 + password_strength: ^0.2.0 dev_dependencies: catalyst_analysis: ^2.0.0 - test: ^1.24.9 + flutter_test: + sdk: flutter diff --git a/catalyst_voices/packages/catalyst_voices_models/test/auth/password_strength_test.dart b/catalyst_voices/packages/catalyst_voices_models/test/auth/password_strength_test.dart new file mode 100644 index 0000000000..108ef53ca9 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/test/auth/password_strength_test.dart @@ -0,0 +1,55 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(PasswordStrength, () { + test('weak password - too short', () { + expect( + PasswordStrength.calculate('123456'), + equals(PasswordStrength.weak), + ); + + expect( + PasswordStrength.calculate('Ab1!@_'), + equals(PasswordStrength.weak), + ); + }); + + test('weak password - too popular', () { + expect( + PasswordStrength.calculate('password'), + equals(PasswordStrength.weak), + ); + }); + + test('weak password - too simple', () { + expect( + /* cSpell:disable */ + PasswordStrength.calculate('simplepw'), + /* cSpell:enable */ + equals(PasswordStrength.weak), + ); + }); + + test('normal password', () { + expect( + PasswordStrength.calculate('Passwd12'), + equals(PasswordStrength.normal), + ); + }); + + test('strong password', () { + expect( + PasswordStrength.calculate('Passwd!@'), + equals(PasswordStrength.strong), + ); + }); + + test('strong password', () { + expect( + PasswordStrength.calculate('4Gf;Rd04WP,RxgBl)n5&RlG'), + equals(PasswordStrength.strong), + ); + }); + }); +} diff --git a/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart index 1f8e778b76..60c4b6c294 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_indicators_example.dart @@ -1,5 +1,6 @@ import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter/material.dart'; @@ -16,9 +17,9 @@ class VoicesIndicatorsExample extends StatelessWidget { appBar: AppBar(title: const Text('Voices Indicators')), body: ListView( padding: const EdgeInsets.symmetric(horizontal: 42, vertical: 24), - children: const [ - Text('Status Indicator'), - Row( + children: [ + const Text('Status Indicator'), + const Row( mainAxisAlignment: MainAxisAlignment.spaceAround, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -30,7 +31,7 @@ class VoicesIndicatorsExample extends StatelessWidget { ), title: Text('Your QR code verified successfully'), body: Text( - 'You can now use your QR-code 
to login into Catalyst.', + 'You can now use your QR-code to login into Catalyst.', ), type: VoicesStatusIndicatorType.success, ), @@ -45,24 +46,24 @@ class VoicesIndicatorsExample extends StatelessWidget { title: Text('Upload failed or QR code not recognized!'), body: Text( 'Are you sure your upload didn’t get interrupted or that ' - 'you provided 
a Catalyst QR code? ' - '

Please try again.', + 'you provided a Catalyst QR code? ' + 'Please try again.', ), type: VoicesStatusIndicatorType.error, ), ), ], ), - Text('Process Stepper Indicator'), - _Steps(), - Text('Linear - Indeterminate'), - VoicesLinearProgressIndicator(), - VoicesLinearProgressIndicator(showTrack: false), - Text('Linear - Fixed'), - VoicesLinearProgressIndicator(value: 0.25), - VoicesLinearProgressIndicator(value: 0.25, showTrack: false), - Text('Circular - Indeterminate'), - Row( + const Text('Process Stepper Indicator'), + const _Steps(), + const Text('Linear - Indeterminate'), + const VoicesLinearProgressIndicator(), + const VoicesLinearProgressIndicator(showTrack: false), + const Text('Linear - Fixed'), + const VoicesLinearProgressIndicator(value: 0.25), + const VoicesLinearProgressIndicator(value: 0.25, showTrack: false), + const Text('Circular - Indeterminate'), + const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ VoicesCircularProgressIndicator(), @@ -70,8 +71,8 @@ class VoicesIndicatorsExample extends StatelessWidget { VoicesCircularProgressIndicator(showTrack: false), ], ), - Text('Circular - Fixed'), - Row( + const Text('Circular - Fixed'), + const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ VoicesCircularProgressIndicator(value: 0.75), @@ -79,8 +80,19 @@ class VoicesIndicatorsExample extends StatelessWidget { VoicesCircularProgressIndicator(value: 0.75, showTrack: false), ], ), - Text('No Internet Connection Banner'), - NoInternetConnectionBanner(), + const Text('No Internet Connection Banner'), + const NoInternetConnectionBanner(), + const Text('Password strength indicator'), + for (final passwordStrength in PasswordStrength.values) + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: VoicesPasswordStrengthIndicator( + passwordStrength: passwordStrength, + ), + ), + ), ].separatedByIndexed( (index, value) { return switch (value.runtimeType) { diff --git a/melos.yaml b/melos.yaml index 7378b9e202..2e38ced9f7 100644 --- a/melos.yaml +++ b/melos.yaml @@ -32,6 +32,7 @@ command: logging: ^1.2.0 meta: ^1.10.0 result_type: ^0.2.0 + password_strength: ^0.2.0 plugin_platform_interface: ^2.1.7 bech32: ^0.2.2 bip32_ed25519: ^0.6.0