Novidades no Flutter FormX 3.0.0

Novidades no Flutter FormX 3.0.0

Como é o processo natural de desenvolvimento de software, notamos uma grande oportunidade de refatorar o Flutter FormX para suportar diferentes arquiteturas de aplicativos com a mesma facilidade que fizemos para o MobX. É isso que entendemos como entrega de valor à comunidade.

Com isso em mente, atualizamos o FormX para oferecer suporte às soluções de gerenciamento de estado MobX e Bloc, compartilhando a mesma implementação básica, para garantir que ele se comporte da mesma maneira e seja mais fácil de expandir, além de usar o FormX mesmo que seu aplicativo não o faça. conte com qualquer uma das soluções de gerenciamento de estado já suportadas.

Esta é uma grande mudança porque reaproveitamos a classe principal da biblioteca, FormX, para ser nossa implementação básica. O MobX será chamado de FormXMobX a partir de agora e você deverá substituir seus mixins por ele.

Bem, chega de atualizações. Estou aqui para contar o que aconteceu, por que aconteceu e como chegou a isso, em detalhes.

O ponto de partida

Deixe-me dar mais contexto. Flutter FormX é uma biblioteca que facilita a construção, reação e validação de formulários em aplicativos Flutter. Ele foi criado a partir de uma necessidade de negócio no Revelo e, por isso, foi inicialmente projetado para funcionar muito bem em apps que utilizam o MobX como solução de gerenciamento de estado.

Você pode implementar e validar formulários em aplicativos Flutter usando Flutter FormX. Nosso objetivo é ser a solução ideal se você precisar de formulários em seu aplicativo.

Na última versão, 2.1.0, o FormX ficou assim:

import 'package:flutter_formx/src/form/form_x_field.dart';
import 'package:mobx/mobx.dart';

/// [FormX] is a helper class to handle forms. [T] stands for the type used to identify the fields
/// such as an enum or a string to later access each of its fields.
///
/// The first step is to call [setupForm] with a map of the fields, their initial values and
/// validators.
///
/// The second step is to call [updateAndValidateField] or [updateField] to update the value of
/// each field once it's changed on the UI and present its value on screen by using either the
/// helper functions such as [getFieldValue] or [getFieldErrorMessage] or by accessing the inputMap
/// directly.
///
/// The third and final step is to call [validateForm] to validate all fields and use the computed
/// value [isFormValid] to show a submit button as enabled or disabled and verify the status of the
/// form.
mixin FormX<T> {
  /// The map of fields, along with all of their properties
  @observable
  final ObservableMap<T, FormXField> inputMap = <T, FormXField>{}.asObservable();

  /// The computed value of the form, true if all fields are valid, false otherwise
  @computed
  bool get isFormValid => inputMap.values.every((element) => element.isValid);

  /// Sets up the form with the given inputs
  ///
  /// This method should be called when starting the viewModel and it already validates the form
  /// without applying any error messages.
  Future<void> setupForm(Map<T, FormXField> inputs) {
    inputMap.addAll(inputs);
    return validateForm(softValidation: true);
  }

  /// Updates the value of the field and validates it, updating the computed variable [isFormValid].
  /// When [softValidation] is true, it doesn't add errors messages to the fields, but updates the
  /// computed variable [isFormValid] which can be used to show a submit button as enabled or disabled
  @action
  Future<void> updateAndValidateField(
    dynamic newValue,
    T type, {
    bool softValidation = false,
  }) async {
    inputMap[type] = await inputMap[type]!
        .updateValue(newValue)
        .validateItem(softValidation: softValidation);
  }

  /// Updates the value of the field without validating it, this does not update the computed variable [isFormValid]
  @action
  void updateField(dynamic newValue, T type) {
    inputMap[type] = inputMap[type]!.updateValue(newValue);
  }

  /// Validates all fields in the form
  ///
  /// Returns bool indicating if the form is valid and when [softValidation] is true,
  /// it doesn't add errors messages to the fields, but updates the computed variable [isFormValid]
  /// which can be used to show a submit button as enabled or disabled
  @action
  Future<bool> validateForm({bool softValidation = false}) async {
    await Future.forEach(inputMap.keys, (type) async {
      inputMap[type] =
          await inputMap[type]!.validateItem(softValidation: softValidation);
    });
    return isFormValid;
  }

  /// Returns the value of the field
  V getFieldValue<V>(dynamic key) => inputMap[key]?.value as V;

  /// Returns the error message of the field
  String? getFieldErrorMessage(dynamic key) => inputMap[key]?.errorMessage;
}

Como você pode notar, esta implementação era fortemente dependente do MobX, continha toda a lógica do formulário e não seria utilizável por um aplicativo com qualquer outra solução de gerenciamento de estado.

A necessidade de mudança: apoiar mais arquiteturas

Ao desenvolver uma biblioteca de código aberto, os principais objetivos geralmente são torná-la acessível ao maior número de pessoas possível e realmente resolver os problemas enfrentados pela comunidade de desenvolvedores. Embora o MobX seja a solução de gerenciamento de estado que decidimos usar nos apps da Revelo, sabíamos que existiam outras soluções com mais adotantes. Com isso e com o objetivo de expandir a biblioteca para alcançar mais desenvolvedores, começamos a pensar em como fazer o Flutter FormX suportar essas outras soluções de gerenciamento de estado.

Decisões, decisões

Ok, mas por qual devemos começar? Estudamos quais das soluções eram mais utilizadas pela comunidade e chegamos à conclusão de que o Bloc seria o melhor caminho a seguir.

Porém, há um problema: nós, como equipe, nunca usamos o Bloc em nossos aplicativos. Tivemos que aprender isso para poder adicioná-lo ao FormX, e foi o que fizemos.

Iterando implementações

Agora tínhamos que pensar em como poderíamos fazer nosso código de forma que cada nova implementação de gerenciamento de estado tivesse a mesma sintaxe e métodos, mesmo que pudessem ser diferentes entre eles.

Decidimos então que, para fazer isso, precisaríamos transformar o FormX em uma classe abstrata com os métodos necessários para gerenciar o estado do formulário, atualizar e validar campos. Além disso, extraímos uma classe de estado especificamente para manter o estado do formulário, já que Bloc usa um estado separadamente.

import 'package:flutter_formx/src/form/formx_field.dart';

abstract class FormX<T> {
 
  Future<void> setupForm(Map<T, FormXField> inputs);
 
  Future<void> updateAndValidateField(
    dynamic newValue,
    T type, {
    bool softValidation = false,
  });

  void updateField(dynamic newValue, T type);

  Future<bool> validateForm({bool softValidation = false});
}
import 'package:flutter_formx/src/form/formx_field.dart';

abstract class FormXState<T> {
  final Map<T, FormXField> inputMap = {};

  bool get isFormValid => inputMap.values.every((element) => element.isValid);

  V getFieldValue<V>(dynamic key) => inputMap[key]?.value as V;

  String? getFieldErrorMessage(dynamic key) => inputMap[key]?.errorMessage;
}

Essa mudança tornou necessária a atualização de nossa implementação MobX, renomeando-a para FormXMobX. A implementação foi a mesma, a única coisa diferente foi que agora era um mixin implementando FormX e FormXState.

Como você também deve notar, esta foi a primeira mudança significativa em nossa atualização.

Depois de garantir que nossa implementação do MobX ainda funcionasse conforme planejado, voltamos nosso foco para a implementação do Bloc. Duas classes surgiram, FormXCubit e FormXState, como segue.

import 'package:bloc/bloc.dart';
import 'package:flutter_formx/src/form/bloc/formx_cubit_state.dart';
import 'package:flutter_formx/src/form/formx.dart';
import 'package:flutter_formx/src/form/formx_field.dart';

/// Bloc implementation of [FormX]
class FormXCubit<T> extends Cubit<FormXCubitState<T>> implements FormX<T> {
  /// When FormXCubit is instantiated, it emits the initial state of the form.
  FormXCubit() : super(const FormXCubitState());

  /// Bloc implementation of [FormX.setupForm].
  @override
  Future<void> setupForm(Map<T, FormXField> inputs) {
    emit(FormXCubitState<T>(inputs));
    return validateForm(softValidation: true);
  }

  Map<T, FormXField> get _cloneStateMap =>
      Map<T, FormXField>.from(state.inputMap);

  /// Bloc implementation of [FormX.updateAndValidateField].
  @override
  Future<void> updateAndValidateField(
    dynamic newValue,
    T type, {
    bool softValidation = false,
  }) async {
    final inputMap = _cloneStateMap;
    inputMap[type] = await inputMap[type]!
        .updateValue(newValue)
        .validateItem(softValidation: softValidation);
    emit(FormXCubitState(inputMap));
  }

  /// Bloc implementation of [FormX.updateField].
  @override
  void updateField(dynamic newValue, T type) {
    final inputMap = _cloneStateMap;
    inputMap[type] = inputMap[type]!.updateValue(newValue);
    emit(FormXCubitState(inputMap));
  }

  /// Bloc implementation of [FormX.validateForm].
  @override
  Future<bool> validateForm({bool softValidation = false}) async {
    final inputMap = _cloneStateMap;
    await Future.forEach(inputMap.keys, (type) async {
      inputMap[type] =
          await inputMap[type]!.validateItem(softValidation: softValidation);
    });
    emit(FormXCubitState<T>(inputMap));
    return state.isFormValid;
  }
}
import 'package:equatable/equatable.dart';
import 'package:flutter_formx/src/form/formx_field.dart';
import 'package:flutter_formx/src/form/formx_state.dart';

/// Bloc state implementation of [FormXState]
class FormXCubitState<T> extends Equatable implements FormXState<T> {
  /// Bloc implementation of [FormXState.inputMap].
  /// This is an observable map of fields
  @override
  final Map<T, FormXField> inputMap;

  /// This class should receive an input map.
  const FormXCubitState([this.inputMap = const {}]);

  /// Bloc implementation of [FormXState.getFieldValue].
  @override
  V getFieldValue<V>(dynamic key) => inputMap[key]?.value as V;

  /// Bloc implementation of [FormXState.getFieldErrorMessage].
  @override
  String? getFieldErrorMessage(dynamic key) => inputMap[key]?.errorMessage;

  /// Bloc implementation of [FormXState.isFormValid].
  @override
  bool get isFormValid => inputMap.values.every((element) => element.isValid);

  @override
  List<Object> get props => [inputMap];
}


Lembre-se, isso foi feito com base em nosso conhecimento recentemente adquirido sobre o Bloc e ficamos felizes com isso. Funcionou conforme o esperado e pudemos replicar facilmente nosso aplicativo de exemplo com ele.

No entanto, queremos criar um código bom, fácil de entender e funcional que seja usado pelo maior número de pessoas possível. Para fazer isso, sentimos que precisávamos de mais. Em seguida, entramos em contato com vários desenvolvedores que conhecemos que trabalham com Bloc e Flutter, pedindo-lhes que fossem nossos “testadores beta” e nos dessem feedback direto sobre o que fizemos antes de divulgá-lo para todos.

Obtendo feedback valioso de outros desenvolvedores

Testamos nossa implementação do Bloc com vários desenvolvedores do Flutter que usam o Bloc diariamente e o feedback deles foi incrível. Com eles, conseguimos fazer funcionar perfeitamente. Eles desempenharam um papel crucial no desenvolvimento deste enorme refatorador, provocando-nos da maneira certa: “Por que vocês não fazem já uma versão agnóstica? Está quase lá!”. Agradecimentos especiais à Rafaela e Indaband por isso!

Esse era o impulso que precisávamos. Já estávamos desconfortáveis em ter métodos com implementações repetidas e preocupados com a possibilidade de ser difícil para a biblioteca ter o alcance que desejávamos sem que tivéssemos que gastar inúmeras horas desenvolvendo implementações para todas as soluções de gerenciamento de estado existentes.

Foi aí que clicou. “Vamos fazer uma implementação básica do FormX, independente do gerenciamento de estado, para que as pessoas possam usá-lo com facilidade em todos os aplicativos e permitir que gerenciem o estado. Nós fornecemos todo o resto.”

Com isso, estava ON! Estávamos entusiasmados e iniciamos a revisão completa do FormX. Foi difícil, mas definitivamente valeu a pena.

Acredito que a principal ideia aqui é: se você tiver a oportunidade de obter feedback externo, faça-o. Pode ser um ponto de viragem para você, como foi para nós.

O resultado

Depois dessa história, você pode querer ver como tudo finalmente aconteceu. Eu sei eu sei. Deixe-me te mostrar!

Em primeiro lugar, temos a implementação básica, que possui métodos de formulário como atualizar um campo ou validar o formulário, mantém o estado do formulário e é o novo FormX.

import 'package:equatable/equatable.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';
import 'package:flutter_formx/src/form/core/formx_state.dart';

class FormX<T> extends Equatable {
  /// This field holds the current FormXState
  late final FormXState<T> state;

  /// Creates an empty FormX instance
  factory FormX.empty() => FormX<T>._();

  /// This method is used to setup the form with the provided initial values
  factory FormX.setupForm(Map<T, FormXField> inputs) => FormX<T>._(inputs);

  /// This method is used to create a FormX instance from a FormXState.
  /// The inputMap used will be the one inside the state
  factory FormX.fromState(FormXState<T> state) => FormX<T>._(state.inputMap);

  /// The FormX constructor.
  /// You should not use it directly, but instead use one of the factory
  /// constructors
  FormX._([Map<T, FormXField<dynamic>>? inputMap]) {
    state = FormXState<T>(inputMap ?? {});
  }

  /// Returns a new FormX with the updated value of the field and validates it,
  /// updating the value of [FormXState.isFormValid].
  ///
  /// When [softValidation] is true, it doesn't add errors messages to the
  /// fields, but updates the value of [FormXState.isFormValid] which can be
  /// used to show a submit button as enabled or disabled.
  Future<FormX<T>> updateAndValidateField(
    newValue,
    T key, {
    bool softValidation = false,
  }) async {
    final inputMap = _cloneStateMap;
    inputMap[key] = await inputMap[key]!
        .updateValue(newValue)
        .validateItem(softValidation: softValidation);

    return FormX<T>._(inputMap);
  }

  /// Returns a new instance of FormX with the new value of the field without
  /// validating it, which means this will not update the value of
  /// [FormXState.isFormValid].
  FormX<T> updateField(newValue, T key) {
    final inputMap = _cloneStateMap;
    inputMap[key] = inputMap[key]!.updateValue(newValue);
    return FormX<T>._(inputMap);
  }

  /// Validates all fields in the form
  ///
  /// Returns a new state with all fields validated and when [softValidation] is
  /// true, it doesn't add errors messages to the fields, but updates the value
  /// of [FormXState.isFormValid]  which can be used to show a submit button as
  /// enabled or disabled
  Future<FormX<T>> validateForm({bool softValidation = false}) async {
    final inputMap = _cloneStateMap;
    await Future.forEach(inputMap.keys, (key) async {
      inputMap[key] =
          await inputMap[key]!.validateItem(softValidation: softValidation);
    });

    return FormX<T>._(inputMap);
  }

  Map<T, FormXField> get _cloneStateMap =>
      Map<T, FormXField>.from(state.inputMap);

  @override
  List<Object?> get props => [state];
}

Observe que mantivemos uma classe de estado, FormXState, que contém o estado atual do FormX e será aquela a ser acessada se você precisar de alguma coisa do formulário, como se o formulário é válido ou não.

import 'package:equatable/equatable.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';

/// [FormXState] is a class that holds the state of a form.
///
/// You can present the fields' values on screen by using either the helper
/// functions such as [FormXState.getFieldValue] or
/// [FormXState.getFieldErrorMessage] or by accessing the inputMap directly.
///
/// You can also use the computed value [FormXState.isFormValid] to show a
/// submit button as enabled or disabled and verify the status of the form.
class FormXState<T> extends Equatable {
  final Map<T, FormXField> _inputMap;

  /// Creates a [FormXState] instance
  const FormXState([this._inputMap = const {}]);

  /// Gets an unmodifiable instance of the inputMap
  Map<T, FormXField> get inputMap => Map.unmodifiable(_inputMap);

  /// Gets a field value by its key
  V getFieldValue<V>(T key) => _inputMap[key]?.value as V;

  /// Gets a field error message by its key. It will return null if the field is valid
  String? getFieldErrorMessage(T key) => _inputMap[key]?.errorMessage;

  /// Returns true if all fields are valid
  bool get isFormValid => _inputMap.values.every((element) => element.isValid);

  @override
  List<Object?> get props => [_inputMap];
}

Com essa abordagem, poderíamos evitar código duplicado de maneira organizada ao adicionar novo suporte ao gerenciamento de estado, que abordaremos agora.

Apoiando abordagens de gestão estadual

Para continuar suportando as soluções de gerenciamento de estados, decidimos seguir o Adapter Design Pattern, o que facilita a criação de novos adaptadores para eles, que precisarão implementar a interface FormXAdapter.

import 'package:flutter_formx/src/form/core/formx_field.dart';

/// Adapter for [FormX].
/// The state management implementations should implement this class
abstract class FormXAdapter<T> {

  void setupForm(Map<T, FormXField> inputs, {bool applySoftValidation = true});

  Future<void> updateAndValidateField(
    dynamic newValue,
    T key, {
    bool softValidation = false,
  });

  void updateField(dynamic newValue, T key);

  Future<void> validateForm({bool softValidation = false});
}

Com isso, temos os seguintes adaptadores:

MobX

import 'package:flutter_formx/src/form/adapters/formx_adapter.dart';
import 'package:flutter_formx/src/form/core/formx.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';
import 'package:flutter_formx/src/form/core/formx_state.dart';
import 'package:mobx/mobx.dart';

/// MobX implementation of [FormX]
mixin FormXMobX<T> implements FormXAdapter<T> {
  final Observable<FormX<T>> _formX = Observable(FormX.empty());

  /// Returns the current FormXState from this instance
  FormXState<T> get state => _formX.value.state;

  /// Returns the current validation status of the form from this instance's state
  bool get isFormValid => state.isFormValid;

  /// Gets a field value from the state by its key
  V getFieldValue<V>(T key) => state.getFieldValue(key);

  /// Gets a field error message from the state by its key.
  /// It will return null if the field is valid
  String? getFieldErrorMessage(T key) => state.getFieldErrorMessage(key);

  @override
  Future<void> setupForm(
    Map<T, FormXField> inputs, {
    bool applySoftValidation = true,
  }) async {
    Action(() {
      _formX.value = FormX.setupForm(inputs);
    })();
    if (applySoftValidation) {
      final validatedForm =
          await _formX.value.validateForm(softValidation: true);
      Action(() {
        _formX.value = validatedForm;
      })();
    }
  }

  @override
  Future<void> updateAndValidateField(
    newValue,
    key, {
    bool softValidation = false,
  }) async {
    final validatedField = await _formX.value
        .updateAndValidateField(newValue, key, softValidation: softValidation);
    Action(() {
      _formX.value = validatedField;
    })();
  }

  @override
  void updateField(newValue, key) {
    Action(() {
      _formX.value = _formX.value.updateField(newValue, key);
    })();
  }

  @override
  Future<bool> validateForm({bool softValidation = false}) async {
    final validatedForm =
        await _formX.value.validateForm(softValidation: softValidation);
    Action(() {
      _formX.value = validatedForm;
    })();
    return state.isFormValid;
  }
}

Bloc

import 'package:bloc/bloc.dart';
import 'package:flutter_formx/src/form/adapters/formx_adapter.dart';
import 'package:flutter_formx/src/form/core/formx.dart';
import 'package:flutter_formx/src/form/core/formx_field.dart';
import 'package:flutter_formx/src/form/core/formx_state.dart';

/// Bloc implementation of [FormX] with [FormXAdapter]
class FormXCubit<T> extends Cubit<FormXState<T>> implements FormXAdapter<T> {
  /// When FormXCubit is instantiated, it emits the initial state of the form.
  FormXCubit() : super(const FormXState({}));

  @override
  Future<void> setupForm(
    Map<T, FormXField> inputs, {
    bool applySoftValidation = true,
  }) async {
    emit(FormXState<T>(inputs));
    if (applySoftValidation) await validateForm(softValidation: true);
  }

  @override
  Future<void> updateAndValidateField(
    dynamic newValue,
    T key, {
    bool softValidation = false,
  }) async {
    final formX = await FormX.fromState(state)
        .updateAndValidateField(newValue, key, softValidation: softValidation);
    emit(formX.state);
  }

  @override
  void updateField(dynamic newValue, T key) {
    final formX = FormX.fromState(state).updateField(newValue, key);
    emit(formX.state);
  }

  @override
  Future<bool> validateForm({bool softValidation = false}) async {
    final formX = await FormX.fromState(state)
        .validateForm(softValidation: softValidation);
    emit(formX.state);
    return state.isFormValid;
  }
}


Observe que ambas as classes não possuem nenhuma lógica de formulário dentro delas. Eles simplesmente usam o FormX para fazer tudo o que precisam e gerenciar o estado de acordo com sua solução de gerenciamento de estado (MobX com observáveis e Cubit emitindo novos estados).

Se você quiser vê-los em ação, confira nosso aplicativo de exemplo! Possui as implementações Vanilla, MobX e Bloc.

Certificando-se de que tudo funciona

Depois de toda essa refatoração, ainda precisamos ter certeza de que funciona conforme o esperado e, o mais importante, garantir que mudanças futuras não resultarão em mudanças indesejadas de comportamento.

Por isso implementamos testes unitários para cada classe e também um teste de integração focado no FormX e seu comportamento.

Também fizemos um formulário funcional com cada implementação (básica, MobX e Bloc) para garantir nos testes de usabilidade que estavam funcionando perfeitamente e não houve diferenças de comportamento entre as soluções de gerenciamento de estado.

Sim, isso é tudo para você!

Por que isso importa para você?

FormX agora é agnóstica no gerenciamento de estado

Agora será mais rápido implementar novos adaptadores para diferentes soluções de gerenciamento de estado, já que toda a lógica está dentro do FormX. Os adaptadores de gerenciamento de estado serão apenas um shell para permitir que seu estado reaja adequadamente quando algo mudar no FormX.

O básico para a vitória!

Com o FormX básico, você poderá usar o FormX em seus aplicativos mesmo se não usar uma das soluções de gerenciamento de estado já suportadas. Basta fazer sua própria implementação de estado com base no FormX básico e criar seus formulários com facilidade.

Mais fácil de contribuir

Você criou uma interface usando nosso FormX básico para uma solução de gerenciamento de estado diferente e funcionou perfeitamente? Nem pense duas vezes, vamos juntos tornar esta biblioteca o mais completa possível.

Validação de formulário

Mantivemos os validadores como estavam e todas as versões do FormX suportam seu uso. Fique tranquilo, nós ajudamos você!


Esperamos que vocês possam ter uma ideia de como aconteceu esse processo, o que foi necessário para realizarmos essa mudança no FormX e, principalmente, por que fizemos o que fizemos.

Estamos entusiasmados com os próximos passos e realmente esperamos que você possa participar deles!