mirror of
				https://gitlab.com/comunic/comunicmobile
				synced 2025-11-04 04:04:18 +00:00 
			
		
		
		
	Implement password policy for account creation
This commit is contained in:
		@@ -16,9 +16,23 @@ class ServerConfigurationHelper {
 | 
				
			|||||||
        (await APIRequest.withoutLogin("server/config").execWithThrow())
 | 
					        (await APIRequest.withoutLogin("server/config").execWithThrow())
 | 
				
			||||||
            .getObject();
 | 
					            .getObject();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final passwordPolicy = response["password_policy"];
 | 
				
			||||||
    final dataConservationPolicy = response["data_conservation_policy"];
 | 
					    final dataConservationPolicy = response["data_conservation_policy"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _config = ServerConfig(
 | 
					    _config = ServerConfig(
 | 
				
			||||||
 | 
					      passwordPolicy: PasswordPolicy(
 | 
				
			||||||
 | 
					        allowMailInPassword: passwordPolicy["allow_email_in_password"],
 | 
				
			||||||
 | 
					        allowNameInPassword: passwordPolicy["allow_name_in_password"],
 | 
				
			||||||
 | 
					        minPasswordLength: passwordPolicy["min_password_length"],
 | 
				
			||||||
 | 
					        minNumberUpperCaseLetters:
 | 
				
			||||||
 | 
					            passwordPolicy["min_number_upper_case_letters"],
 | 
				
			||||||
 | 
					        minNumberLowerCaseLetters:
 | 
				
			||||||
 | 
					            passwordPolicy["min_number_lower_case_letters"],
 | 
				
			||||||
 | 
					        minNumberDigits: passwordPolicy["min_number_digits"],
 | 
				
			||||||
 | 
					        minNumberSpecialCharacters:
 | 
				
			||||||
 | 
					            passwordPolicy["min_number_special_characters"],
 | 
				
			||||||
 | 
					        minCategoriesPresence: passwordPolicy["min_categories_presence"],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
      dataConservationPolicy: ServerDataConservationPolicy(
 | 
					      dataConservationPolicy: ServerDataConservationPolicy(
 | 
				
			||||||
        minInactiveAccountLifetime:
 | 
					        minInactiveAccountLifetime:
 | 
				
			||||||
            dataConservationPolicy["min_inactive_account_lifetime"],
 | 
					            dataConservationPolicy["min_inactive_account_lifetime"],
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,6 +4,35 @@ import 'package:flutter/widgets.dart';
 | 
				
			|||||||
///
 | 
					///
 | 
				
			||||||
/// @author Pierre Hubert
 | 
					/// @author Pierre Hubert
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PasswordPolicy {
 | 
				
			||||||
 | 
					  final bool allowMailInPassword;
 | 
				
			||||||
 | 
					  final bool allowNameInPassword;
 | 
				
			||||||
 | 
					  final int minPasswordLength;
 | 
				
			||||||
 | 
					  final int minNumberUpperCaseLetters;
 | 
				
			||||||
 | 
					  final int minNumberLowerCaseLetters;
 | 
				
			||||||
 | 
					  final int minNumberDigits;
 | 
				
			||||||
 | 
					  final int minNumberSpecialCharacters;
 | 
				
			||||||
 | 
					  final int minCategoriesPresence;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const PasswordPolicy({
 | 
				
			||||||
 | 
					    @required this.allowMailInPassword,
 | 
				
			||||||
 | 
					    @required this.allowNameInPassword,
 | 
				
			||||||
 | 
					    @required this.minPasswordLength,
 | 
				
			||||||
 | 
					    @required this.minNumberUpperCaseLetters,
 | 
				
			||||||
 | 
					    @required this.minNumberLowerCaseLetters,
 | 
				
			||||||
 | 
					    @required this.minNumberDigits,
 | 
				
			||||||
 | 
					    @required this.minNumberSpecialCharacters,
 | 
				
			||||||
 | 
					    @required this.minCategoriesPresence,
 | 
				
			||||||
 | 
					  })  : assert(allowMailInPassword != null),
 | 
				
			||||||
 | 
					        assert(allowNameInPassword != null),
 | 
				
			||||||
 | 
					        assert(minPasswordLength != null),
 | 
				
			||||||
 | 
					        assert(minNumberUpperCaseLetters != null),
 | 
				
			||||||
 | 
					        assert(minNumberLowerCaseLetters != null),
 | 
				
			||||||
 | 
					        assert(minNumberDigits != null),
 | 
				
			||||||
 | 
					        assert(minNumberSpecialCharacters != null),
 | 
				
			||||||
 | 
					        assert(minCategoriesPresence != null);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ServerDataConservationPolicy {
 | 
					class ServerDataConservationPolicy {
 | 
				
			||||||
  final int minInactiveAccountLifetime;
 | 
					  final int minInactiveAccountLifetime;
 | 
				
			||||||
  final int minNotificationLifetime;
 | 
					  final int minNotificationLifetime;
 | 
				
			||||||
@@ -28,9 +57,12 @@ class ServerDataConservationPolicy {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ServerConfig {
 | 
					class ServerConfig {
 | 
				
			||||||
 | 
					  final PasswordPolicy passwordPolicy;
 | 
				
			||||||
  final ServerDataConservationPolicy dataConservationPolicy;
 | 
					  final ServerDataConservationPolicy dataConservationPolicy;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const ServerConfig({
 | 
					  const ServerConfig({
 | 
				
			||||||
 | 
					    @required this.passwordPolicy,
 | 
				
			||||||
    @required this.dataConservationPolicy,
 | 
					    @required this.dataConservationPolicy,
 | 
				
			||||||
  }) : assert(dataConservationPolicy != null);
 | 
					  })  : assert(passwordPolicy != null),
 | 
				
			||||||
 | 
					        assert(dataConservationPolicy != null);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,9 @@
 | 
				
			|||||||
import 'package:comunic/helpers/account_helper.dart';
 | 
					import 'package:comunic/helpers/account_helper.dart';
 | 
				
			||||||
 | 
					import 'package:comunic/helpers/server_config_helper.dart';
 | 
				
			||||||
import 'package:comunic/models/config.dart';
 | 
					import 'package:comunic/models/config.dart';
 | 
				
			||||||
import 'package:comunic/models/new_account.dart';
 | 
					import 'package:comunic/models/new_account.dart';
 | 
				
			||||||
 | 
					import 'package:comunic/ui/widgets/async_screen_widget.dart';
 | 
				
			||||||
 | 
					import 'package:comunic/ui/widgets/new_password_input_widget.dart';
 | 
				
			||||||
import 'package:comunic/utils/input_utils.dart';
 | 
					import 'package:comunic/utils/input_utils.dart';
 | 
				
			||||||
import 'package:comunic/utils/intl_utils.dart';
 | 
					import 'package:comunic/utils/intl_utils.dart';
 | 
				
			||||||
import 'package:comunic/utils/ui_utils.dart';
 | 
					import 'package:comunic/utils/ui_utils.dart';
 | 
				
			||||||
@@ -34,7 +37,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
 | 
				
			|||||||
  final _firstNameController = TextEditingController();
 | 
					  final _firstNameController = TextEditingController();
 | 
				
			||||||
  final _lastNameController = TextEditingController();
 | 
					  final _lastNameController = TextEditingController();
 | 
				
			||||||
  final _emailController = TextEditingController();
 | 
					  final _emailController = TextEditingController();
 | 
				
			||||||
  final _passwordController = TextEditingController();
 | 
					  final _passwordInputKey = GlobalKey<NewPasswordInputWidgetState>();
 | 
				
			||||||
  final _verifyPasswordController = TextEditingController();
 | 
					  final _verifyPasswordController = TextEditingController();
 | 
				
			||||||
  bool _acceptedTOS = false;
 | 
					  bool _acceptedTOS = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -49,10 +52,11 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  bool get _isEmailValid => validateEmail(_emailController.text);
 | 
					  bool get _isEmailValid => validateEmail(_emailController.text);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool get _isPasswordValid => _passwordController.text.length > 3;
 | 
					  bool get _isPasswordValid => _passwordInputKey.currentState.valid;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool get _isPasswordConfirmationValid =>
 | 
					  bool get _isPasswordConfirmationValid =>
 | 
				
			||||||
      _passwordController.text == _verifyPasswordController.text;
 | 
					      _passwordInputKey.currentState != null &&
 | 
				
			||||||
 | 
					      _passwordInputKey.currentState.value == _verifyPasswordController.text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  bool get _isFormValid =>
 | 
					  bool get _isFormValid =>
 | 
				
			||||||
      _isFirstNameValid &&
 | 
					      _isFirstNameValid &&
 | 
				
			||||||
@@ -69,10 +73,16 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
 | 
				
			|||||||
          ? tr(
 | 
					          ? tr(
 | 
				
			||||||
              "Too many accounts have been created from this IP address for now. Please try again later.")
 | 
					              "Too many accounts have been created from this IP address for now. Please try again later.")
 | 
				
			||||||
          : tr(
 | 
					          : tr(
 | 
				
			||||||
              "An error occured while creating your account. Please try again.");
 | 
					              "An error occurred while creating your account. Please try again.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) => AsyncScreenWidget(
 | 
				
			||||||
 | 
					        onReload: () => ServerConfigurationHelper.ensureLoaded(),
 | 
				
			||||||
 | 
					        onBuild: () => _buildForm(),
 | 
				
			||||||
 | 
					        errorMessage: tr("Failed to load server configuration !"),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Widget _buildForm() {
 | 
				
			||||||
    if (_isCreating)
 | 
					    if (_isCreating)
 | 
				
			||||||
      return Center(
 | 
					      return Center(
 | 
				
			||||||
        child: CircularProgressIndicator(),
 | 
					        child: CircularProgressIndicator(),
 | 
				
			||||||
@@ -124,15 +134,14 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
 | 
				
			|||||||
              ),
 | 
					              ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              // Password
 | 
					              // Password
 | 
				
			||||||
              _InputEntry(
 | 
					              NewPasswordInputWidget(
 | 
				
			||||||
                controller: _passwordController,
 | 
					                key: _passwordInputKey,
 | 
				
			||||||
                label: tr("Password"),
 | 
					                label: tr("Password"),
 | 
				
			||||||
                onEdited: _updateUI,
 | 
					                onEdited: _updateUI,
 | 
				
			||||||
                icon: Icon(Icons.lock),
 | 
					                icon: Icon(Icons.lock),
 | 
				
			||||||
                isPassword: true,
 | 
					                email: _emailController.text,
 | 
				
			||||||
                error: _showErrors && !_isPasswordValid
 | 
					                firstName: _firstNameController.text,
 | 
				
			||||||
                    ? tr("Invalid password!")
 | 
					                lastName: _lastNameController.text,
 | 
				
			||||||
                    : null,
 | 
					 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              // Verify password
 | 
					              // Verify password
 | 
				
			||||||
@@ -206,7 +215,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
 | 
				
			|||||||
      firstName: _firstNameController.text,
 | 
					      firstName: _firstNameController.text,
 | 
				
			||||||
      lastName: _lastNameController.text,
 | 
					      lastName: _lastNameController.text,
 | 
				
			||||||
      email: _emailController.text,
 | 
					      email: _emailController.text,
 | 
				
			||||||
      password: _passwordController.text,
 | 
					      password: _passwordInputKey.currentState.value,
 | 
				
			||||||
    ));
 | 
					    ));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setState(() {
 | 
					    setState(() {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										164
									
								
								lib/ui/widgets/new_password_input_widget.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								lib/ui/widgets/new_password_input_widget.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,164 @@
 | 
				
			|||||||
 | 
					import 'package:comunic/helpers/server_config_helper.dart';
 | 
				
			||||||
 | 
					import 'package:comunic/models/server_config.dart';
 | 
				
			||||||
 | 
					import 'package:comunic/models/user.dart';
 | 
				
			||||||
 | 
					import 'package:comunic/utils/intl_utils.dart';
 | 
				
			||||||
 | 
					import 'package:comunic/utils/ui_utils.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// New password input widget
 | 
				
			||||||
 | 
					///
 | 
				
			||||||
 | 
					/// @author Pierre Hubert
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewPasswordInputWidget extends StatefulWidget {
 | 
				
			||||||
 | 
					  final Widget icon;
 | 
				
			||||||
 | 
					  final VoidCallback onEdited;
 | 
				
			||||||
 | 
					  final String label;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final User user;
 | 
				
			||||||
 | 
					  final String firstName;
 | 
				
			||||||
 | 
					  final String lastName;
 | 
				
			||||||
 | 
					  final String email;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const NewPasswordInputWidget({
 | 
				
			||||||
 | 
					    Key key,
 | 
				
			||||||
 | 
					    this.icon,
 | 
				
			||||||
 | 
					    this.onEdited,
 | 
				
			||||||
 | 
					    @required this.label,
 | 
				
			||||||
 | 
					    this.user,
 | 
				
			||||||
 | 
					    this.firstName,
 | 
				
			||||||
 | 
					    this.lastName,
 | 
				
			||||||
 | 
					    this.email,
 | 
				
			||||||
 | 
					  }) : super(key: key);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  NewPasswordInputWidgetState createState() => NewPasswordInputWidgetState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewPasswordInputWidgetState extends State<NewPasswordInputWidget> {
 | 
				
			||||||
 | 
					  final TextEditingController _controller = TextEditingController();
 | 
				
			||||||
 | 
					  bool _ready = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String get value => _controller.text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool get valid => _ready && value.isNotEmpty && (_errorMessage ?? "").isEmpty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  PasswordPolicy get _policy => ServerConfigurationHelper.config.passwordPolicy;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _init();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Make sure server configuration is loaded
 | 
				
			||||||
 | 
					  void _init() async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await ServerConfigurationHelper.ensureLoaded();
 | 
				
			||||||
 | 
					      setState(() => _ready = true);
 | 
				
			||||||
 | 
					    } catch (e, s) {
 | 
				
			||||||
 | 
					      print("Failed to initialize NewPasswordInputWidget! : $e - $s");
 | 
				
			||||||
 | 
					      showSimpleSnack(context, tr("Failed to retrieve server configuration!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void didUpdateWidget(covariant NewPasswordInputWidget oldWidget) {
 | 
				
			||||||
 | 
					    super.didUpdateWidget(oldWidget);
 | 
				
			||||||
 | 
					    setState(() {});
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) => TextField(
 | 
				
			||||||
 | 
					        controller: _controller,
 | 
				
			||||||
 | 
					        obscureText: true,
 | 
				
			||||||
 | 
					        onChanged: (s) => _onChanged(),
 | 
				
			||||||
 | 
					        decoration: InputDecoration(
 | 
				
			||||||
 | 
					          errorText: _errorMessage,
 | 
				
			||||||
 | 
					          errorMaxLines: 3,
 | 
				
			||||||
 | 
					          icon: widget.icon,
 | 
				
			||||||
 | 
					          labelText: widget.label,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onChanged() {
 | 
				
			||||||
 | 
					    setState(() {});
 | 
				
			||||||
 | 
					    if (widget.onEdited != null) widget.onEdited();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Generate an error message associated with current password
 | 
				
			||||||
 | 
					  String get _errorMessage {
 | 
				
			||||||
 | 
					    if (!_ready || value.isEmpty) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Mandatory checks
 | 
				
			||||||
 | 
					    if (!_policy.allowMailInPassword &&
 | 
				
			||||||
 | 
					        (widget.email ?? "").isNotEmpty &&
 | 
				
			||||||
 | 
					        (widget.email.toLowerCase().contains(value.toLowerCase()) ||
 | 
				
			||||||
 | 
					            value.toLowerCase().contains(widget.email.toLowerCase()))) {
 | 
				
			||||||
 | 
					      return tr("Your password must not contains part of your email address!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!_policy.allowMailInPassword &&
 | 
				
			||||||
 | 
					        (widget.email ?? "").isNotEmpty &&
 | 
				
			||||||
 | 
					        (widget.email.toLowerCase().contains(value.toLowerCase()) ||
 | 
				
			||||||
 | 
					            value.toLowerCase().contains(widget.email.toLowerCase()))) {
 | 
				
			||||||
 | 
					      return tr("Your password must not contains part of your email address!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!_policy.allowNameInPassword &&
 | 
				
			||||||
 | 
					        (widget.firstName ?? "").isNotEmpty &&
 | 
				
			||||||
 | 
					        value.toLowerCase().contains(widget.firstName.toLowerCase())) {
 | 
				
			||||||
 | 
					      return tr("Your password must not contains your first name!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!_policy.allowNameInPassword &&
 | 
				
			||||||
 | 
					        (widget.lastName ?? "").isNotEmpty &&
 | 
				
			||||||
 | 
					        value.toLowerCase().contains(widget.lastName.toLowerCase())) {
 | 
				
			||||||
 | 
					      return tr("Your password must not contains your last name!");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_policy.minPasswordLength > value.length) {
 | 
				
			||||||
 | 
					      return tr(
 | 
				
			||||||
 | 
					        "Your password must be composed of at least %num% characters!",
 | 
				
			||||||
 | 
					        args: {
 | 
				
			||||||
 | 
					          "num": _policy.minPasswordLength.toString(),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Characteristics check
 | 
				
			||||||
 | 
					    var count = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_hasCharacteristic(RegExp(r'[A-Z]'), _policy.minNumberUpperCaseLetters))
 | 
				
			||||||
 | 
					      count++;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_hasCharacteristic(RegExp(r'[a-z]'), _policy.minNumberLowerCaseLetters))
 | 
				
			||||||
 | 
					      count++;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_hasCharacteristic(RegExp(r'[0-9]'), _policy.minNumberDigits)) count++;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (_hasCharacteristic(
 | 
				
			||||||
 | 
					        RegExp(r'[^A-Za-z0-9]'), _policy.minNumberSpecialCharacters)) count++;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (count >= _policy.minCategoriesPresence) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return tr(
 | 
				
			||||||
 | 
					      "Your password must contains characters of at least %num% of the following categories : %upper% upper case letter, %lower% lowercase letter, %digit% digit, %special% special character.",
 | 
				
			||||||
 | 
					      args: {
 | 
				
			||||||
 | 
					        "num": (_policy.minCategoriesPresence == 4
 | 
				
			||||||
 | 
					            ? tr("ALL")
 | 
				
			||||||
 | 
					            : _policy.minCategoriesPresence.toString()),
 | 
				
			||||||
 | 
					        "upper": _policy.minNumberUpperCaseLetters.toString(),
 | 
				
			||||||
 | 
					        "lower": _policy.minNumberLowerCaseLetters.toString(),
 | 
				
			||||||
 | 
					        "digit": _policy.minNumberDigits.toString(),
 | 
				
			||||||
 | 
					        "special": _policy.minNumberSpecialCharacters.toString(),
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _hasCharacteristic(RegExp exp, int requiredCount) {
 | 
				
			||||||
 | 
					    if (requiredCount < 1) return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return exp.allMatches(value).length >= requiredCount;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user