Skip to content

injected_text_editing_api

GIfatahTH edited this page Oct 4, 2021 · 8 revisions

TextEditingController is yet another controller that has a dedicated Injected state that will make it easier for us:

  • Create and automatically dispose of a TextEditingController,
  • Associate the TextEditingController with a TextField or TextFormField,
  • Change the value of the text and cache it,
  • Easily work with a collection of TextFields (Form).
  • Manage FocusNote, to dynamically change the focus to the next field to edit.

To deal with form input fields other than TextField use InjectedFormField

Table of Contents

Creation of the InjectedTextEditing state

Let's take the case of two TextFields: one for the email and the other for the password.

final email =  RM.injectTextEditing():

final password = RM.injectTextEditing(
  text: '',
  selection: TextSelection.collapsed(offset: -1),
  composing: TextRange.empty,
  validators: [
    (String? value) {
      if (value!.length < 6) {
        return "Password must have at least 6 characters";
      }
      return null;
    },
  ],
  validateOnTyping: true,
  validateOnLoseFocus: false,
  isReadOnly: false,
  isEnabled: true,
  autoDispose: true,
  onTextEditing :(password){
    //fired when ever input text or selection changes
  }
);
  • text, selection and composing used as in Flutter.

  • The validators is a list of validation rules the field should pass. validators exposes the text that the user entered. If any of the validation callbacks return a non empty strings, the filed is considered non valid. For the field to be valid all validators must return null. example:

        final _email = RM.injectTextEditing(
        validators: [
          (value) {
              //Frontend validation
              if (!Validators.isValidEmail(value)) {
                return 'Enter a valid email';
              }
            },
        ]
      );

    The validations performed here are frontend validation. To do backend validation you must use InjectedForm.

  • validateOnTyping: Whether to validate the input while the user is typing. The default value depends on whether the linked [TextField] is inside or outside [OnFormBuilder]:

    • If outside: it default to true if validateOnLoseFocus is false.
    • If inside: it defaults to false if [InjectedForm.autovalidateMode] is [AutovalidateMode.disabled], otherwise it defaults to true.

    If validateOnTyping is set to false, the text is not validate on typing. The text can be validate manually by invoking [InjectedTextEditing.validate].

  • validateOnLoseFocus Whether to validate the input just after the user finishes typing and the field loses focus. It defaults to true if the linked [TextField] is inside [OnFormBuilder] and defaults to false if it is outside. Once the [TextField] loses focus and if it fails to validate, the field will auto validate on typing the next time the user starts typing.

    For validateOnLoseFocus to work you have to set the [TextField]'s [FocusNode]to use [InjectedTextEditing.focusNode] Example:

      final email =  RM.injectTextEditing();
      // In the widget tree
      TextField(
          controller: email.controller,
          focusNode: email.focusNode, //It is auto disposed of.
      ),
  • The InjectedTextEditing is automatically deleted when it is no longer used. If you want to keep the data and use it in another TextField, set autoDispose to false.

  • isReadOnly: If true the [TextField] is clickable and selectable but not editable. Later on, you can set it using [InjectedTextEditing.isReadOnly].

    All input fields are set to be read only if they are inside a [OnFormBuilder] and the form is waiting for submission to resolve.

  • isEnabled: If false the [TextField] is disabled. Later on, you can set it using [InjectedTextEditing.isEnable]. You can enable or disable all input fields inside [OnFormBuilder] using [InjectedForm.isEnabled] setter.

    For isEnabled to work you have to set the [TextField]'s enable property to use [InjectedTextEditing.isEnabled]

    Example:

      final email =  RM.injectTextEditing():
    
      // In the widget tree
      TextField(
        controller: email.controller,
        enabled: email.isEnabled,
      ),

Link the InjectedTextEditing with a TextField

//Basically, define only the controller
//No need to define onChange nor onSave callbacks
TextField(
    controller: email.controller,
),

//Or for full features
TextField(
    controller: email.controller,
    focusNode: email.focusNode, //It is auto disposed of.
    decoration:  InputDecoration(
        errorText: email.error, //To display the error message.
    ),
    onSubmitted: (_) {
        //Focus on the password TextField after submission
        password.focusNode.requestFocus();
    },
),

Consuming the valid data after submission

_submit() {
  if (email.isValid && password.isValid) {
      print('Authenticate with ${email.text} and ${password.text}');
  }
}
  • You can reset any TextField to its initial text using:
email.reset();
password.reset();

Working with forms

If the application you are working on contains dozens of TextFields, it becomes tedious to process each field individually. Form helps us collect many TextFields and manage them as a unit.

Injected the form state

final form = RM.injectForm(
  //optional parameters

  autovalidateMode: AutovalidateMode.disable,
  autoFocusOnFirstError: true,
  submit: () async {
    //This is the default submission logic,
    //It may be override when calling form.submit( () async { });
    //It may contains server validation.
   await serverError =  authRepository.signInWithEmailAndPassword(
      email: email.text,
      password: password.text,
    );
    //after server validation
    if(serverError == 'Invalid-Email'){
      email.error = 'Invalid email';
    }
    if(serverError == 'Weak-Password'){
      email.error = 'Password must have more the 6 characters';
    }
  },
  submissionSideEffects: SideEffects(..), // for side effects
  onSubmitting: () {
    // called while waiting for form submission,
  },
  onSubmitted: () {
    // called after form is successfully submitted
    // For example navigation to user page
  }

);
  • InjectedForm has one optional parameter. “autovalidateMode” is of type “AutovalidateMode”. As in Flutter, It can take one of three enumeration values: - AutovalidateMode.disable: The form is validated manually by calling form.validate() - AutovalidateMode.always: The form is always validated - AutovalidateMode.onUserInteraction: The form is not validated until the user has started typing.
  • autoFocusOnFirstError if set to true (default), after form validation the first non valid field is get focus.
  • submit contains the user logic for form submission. It will be invoked whe the form.submit() si called without argument. (See from submission below)
  • For side effects you have onSubmitting and onSubmitted.

Link InjectedForm to InjectedTextEditing states

In the user interface, we put the TextFields that we want to associate with the form inside the OnFormBuilder widget:

OnFormBuilder(
  listenTo: form,
  builder: () => Column(
    children: <Widget>[
        TextField(
            focusNode: email.focusNode,
            controller: email.controller,
            decoration: InputDecoration(
              errorText: email.error,
            ),
            onSubmitted: (_) {
              //request the password node
              password.focusNode.requestFocus();
            },
        ),
        TextField(
            focusNode: password.focusNode,
            controller: password.controller,
            decoration: new InputDecoration(
              errorText: password.error,
            ),
            onSubmitted: (_) {
              //request the submit button node
              form.submitFocusNode.requestFocus();
            },
        ),
        OnFormSubmissionBuilder(
          listenTo: form
          onSubmitting: () => CircularProgressIndicator(),
          child : ElevatedButton(
            focusNode: form.submitFocusNode,
            onPressed: (){
                form.submit();
            },
            child: Text('Submit'),
          ),
        ),     
    ],
  ),
),
  • We only use TextField widgets, no need to use TextFormField
  • To validate all fields, use: form.validate()
  • To reset all fields to their initial values, use: form.reset()
  • To check that all fields are valid, use form.isValid
  • Each InjectedTextEditing has an associated FocusNode.
  • The InjectedForm is associated with a FocusNote to be used in the submit button.
  • All TextEditingControllers and FocusNotes are automatically disposed of when they are no longer in use.
  • OnFormSubmissionBuilder widget is used to listen to form submission. (See below)
  • OnSubmissionBuilder widget is used to listen to form submission. (See below)

Form submission

To submit a form, you can use the submit method :

//If called without argument, it will use the default submit callback defined while initializing the InjectForm
form.submit();

//Some time is is more convenient to define the submission logic inside the widget tree.
form.submit(() async {
    //submission logic,
    //It may contains server validation.
    authRepository.signInWithEmailAndPassword(
      email: email.text,
      password: password.text,
    );

    //after server response
    if(serverError == 'Invalid-Email'){
      email.error = 'Invalid email';
    }
    if(serverError == 'Weak-Password'){
      email.error = 'Password must have more the 6 characters';
    }
});

Here is an example take form This auth example

In the user repository,We sing in using FireBaseAuth:

  Future<User?> _signInWithEmailAndPassword(
    String email,
    String password,
  ) async {
    try {
      final firebase.UserCredential authResult =
          await _firebaseAuth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
      return _fromFireBaseUserToUser(authResult.user);
    } catch (e) {
      if (e is firebase.FirebaseAuthException) {
        switch (e.code) {
          case 'invalid-email':
            throw EmailException('Email address is not valid');
          case 'user-disabled':
            throw EmailException(
                'User corresponding to the given email has been disabled');
          case 'user-not-found':
            throw EmailException(
                'There is no user corresponding to the given email');
          case 'wrong-password':
            throw PasswordException('Password is invalid for the given email');
          default:
            throw SignInException(
              title: 'Create use with email and password',
              code: e.code,
              message: e.message,
            );
        }
      }
      rethrow;
    }
  }

In the UI :

final _email = RM.injectTextEditing(
  validators: [(String? val) {
    //Frontend validation
    if (!Validators.isValidEmail(val!)) {
      return 'Enter a valid email';
    }
  }],
);
final _password = RM.injectTextEditing(
  validators: [
    (String? val) {
      if (!Validators.isValidPassword(val!)) {
          return 'Enter a valid password';
        }
    }],
  validateOnTyping: true,
);

final _confirmationPassword = RM.injectTextEditing(
  validators: [
    (String? val) {
      if (_password.text != val) {
        return 'Passwords do not match';
      }
  }],
  validateOnTyping: true,
);

final _form = RM.injectForm();

class SignInRegisterFormPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(10),
        child: FormWidget(),
      ),
    );
  }
}

class FormWidget extends StatelessWidget {
  final _isRegister = false.inj();

  @override
  Widget build(BuildContext context) {
    return OnFormBuilder(
        listenTo: form,
        builder: () => Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            TextField(
              controller: _email.controller,
              focusNode: _email.focusNode,
              decoration: InputDecoration(
                icon: Icon(Icons.email),
                labelText: 'Email',
                errorText: _email.error,
              ),
              keyboardType: TextInputType.emailAddress,
              autocorrect: false,
              onSubmitted: (_) {
                _password.focusNode.requestFocus();
              },
            ),
            TextField(
              controller: _password.controller,
              focusNode: _password.focusNode,
              decoration: InputDecoration(
                icon: Icon(Icons.lock),
                labelText: 'Password',
                errorText: _password.error,
              ),
              obscureText: true,
              autocorrect: false,
              onSubmitted: (_) {
                if (_isRegister.state) {
                  _confirmationPassword.focusNode.requestFocus();
                } else {
                  _form.submitFocusNode.requestFocus();
                }
              },
            ),

            OnReactive(
              () => Column(
                  children: [
                    _isRegister.state
                        ? TextField(
                            controller: _confirmationPassword.controller,
                            focusNode: _confirmationPassword.focusNode,
                            decoration: InputDecoration(
                              icon: Icon(Icons.lock),
                              labelText: 'Confirm Password',
                              errorText: _confirmationPassword.error,
                            ),
                            obscureText: true,
                            autocorrect: false,
                            onSubmitted: (_) {
                              _form.submitFocusNode.requestFocus();
                            },
                          )
                        : Container(),
                    const SizedBox(height: 10),
                    Row(
                      children: <Widget>[
                        Checkbox(
                          value: _isRegister.state,
                          onChanged: (value) {
                            _isRegister.state = value!;
                          },
                        ),
                        Text(' I do not have an account')
                      ],
                    ),
                    OnFormSubmissionBuilder(
                      listenTo: form,
                      onSubmitting: () =>
                          Center(child: CircularProgressIndicator()),
                      child: ElevatedButton(
                        focusNode: _form.submitFocusNode,
                        child: _isRegister.state
                            ? Text('Register')
                            : Text('Sign in'),
                        onPressed: () {
                          _form.submit(
                            () async {
                              if (_isRegister.state) {
                                await user.auth.signUp(
                                  (_) => UserParam(
                                    signUp: SignUp.withEmailAndPassword,
                                    email: _email.state,
                                    password: _password.state,
                                  ),
                                );
                              } else {
                                await user.auth.signIn(
                                  (_) => UserParam(
                                    signIn: SignIn.withEmailAndPassword,
                                    email: _email.state,
                                    password: _password.state,
                                  ),
                                );
                                //Server validation
                                if (user.error is EmailException) {
                                  _email.error = user.error.message;
                                }
                                if (user.error is PasswordException) {
                                  _password.error = user.error.message;
                                }
                              }
                            },
                          );
                        },
                      ),
                    ),
                  ],
                ),
          ],
        ),
    );
  }
}

TextField validation and NodeFocus

If a TextField is put inside OnFormBuilder the validation logic defaults to the following:

  • By default form validation mode is AutovalidateMode.disable.
  • The validateOnLoseFocus is set to true and validateOnTyping is set to false. That is, the input text will not be validated until the field first loses focus.
  • After a field loses focus, and if the input text is not valid, the validateOnTyping is set to true so that the field is validated on the fly.
  • When the form is validated by calling form.validate or form.submit, and if validation fails, the first non-valid field will get focus.

Add isReadOnly and isEnable to InjectedTextEditing, and OnFormBuilder

In some scenarios, we need to disable an input field or just make it read only.

Now InjectedTextEditing and InjectedFormField have:

  • isEnabled: If set to false, it will make the field non-selectable; non-focusable, non-editable.
  • isReadOnly: If set to true, the field is selectable, focusable but not editable.

Later on, you can change value of the isEnabled, isReadOnly properties:

final myText = RM.injectedTextEditing(
  isEnabled: false,
  isReadOnly: true,
);
final myCheckBox = RM.injectedFormField(
  isEnabled: false,
  isReadOnly: true,
);

// In the widget tree
Column(
  children: [
    // For TextFields you have to explicitly define enabled and readOnly parameters.
    TextField(
      controller: myText.controller,
      enabled: myText.isEnabled,
      readOnly: myText.isReadOnly,
    ),
    //In contrast in OnFormFieldBuilder enabled and readOnly are implicitly assigned.
    OnFormFieldBuilder<bool>(
      listenTo: myCheckBox,
      builder: (value, onChanged){
        return CheckBoxListTile(
          value: value,
          onChanged: onChanged,
          title: Text('Accept me'),
        );
      }
    )
  ]
)

// To toggle isEnabled and isReadOnly:
myText.isEnabled = true;
myText.isReadOnly = false;
myCheckBox.isEnabled = true;
myCheckBox.isReadOnly = false;

In case we have many input fields we can enable or disable each field individually but this my be very tedious. InjectedForm and OnFormBuilder are here to help us.

Let's imagine a case where we want to disable all input fields (or just make them read only) while the form is submitting.

final isEnabledRM = true.inj();
final formRM =  RM.injectForm(
  submissionSideEffects: SideEffects.onOrElse(
    onWaiting: ()=> isEnabledRM = false,
    orElse: (_)=> isEnabledRM = true,
    submit: () => repository.submitForm( ... ),
  ),
);

// In the widget tree
OnFormBuilder(
  listenTo: formRM,
  // Adding this all child input's enabled and readOnly properties are controlled.
  isEnabledRM: isEnabledRM,

  // // Similar if you want to make it readOnly
  //isReadOnlyRM: isReadOnlyRM,

  builder: () => Column(
      children: [
        TextField(
          controller: myText.controller,
          enabled: myText.isEnabled,
          readOnly: myText.isReadOnly,
        ),
        OnFormFieldBuilder<bool>(
          listenTo: myCheckBox,
          builder: (value, onChanged){
            return CheckBoxListTile(
              value: value,
              onChanged: onChanged,
              title: Text('Accept me'),
            );
          }
        )
      ]
  ),
)

As you can use many nested OnFormFieldBuilder for the same formRM you can disable (or make readOnly) only a part of the field inputs.For example, sometimes there is a checkbox as an agreement to continue to the next section, before that all/some fields on the next section are disabled.

Clone this wiki locally