¿Qué hay de nuevo en Flutter FormX 3.0.0?

¿Qué hay de nuevo en Flutter FormX 3.0.0?

Como es natural en el desarrollo de software, siempre hay cabida para ser todavía mejores. Por eso, pusimos la lupa en Flutter FormX y hallamos potencial para hacerlo más amigable con la arquitectura de diferentes apps, igual que lo hicimos con MobX. Eso es lo que entendemos por entregar valor a la comunidad.

Con eso en mente, actualizamos FormX para hacerlo compatible tanto con MobX como con soluciones de manejo de estados de Bloc, a fin de que compartan ellos una misma naturaleza interna. De esta manera, garantizamos que actuarán de la misma manera, la facilidad de expansión y que FormX responda incluso si tu app no confía en una o varias de las soluciones de manejo de estados.

Esto es un gran cambio. Para empezar, hicimos de FormX, nuestra clase principal de la librería, para ser nuestra implementación estándar. Por ende, MobX pasará a llamarse FormXMobX desde ahora y sería recomendable reemplazar tus mezclas con eso.

Bueno, suficiente con noticias, ¿no? Mejor te decimos más sobre de este cambio, el porqué de éste y cómo llegamos a él.


El punto de partida

Déjanos darte un poco de contexto. Flutter FormX es una librería que facilita la creación y validación de formas en apps de Flutter. Fue creada en Revelo a partir de una necesidad de negocio y, debido a eso, se diseñó en principio para trabajar bien en apps que usaran MobX como solución de manejo de estados.

Gracias a FormX, puedes implementar y validar formas en apps de Flutter. Con esto, apuntamos a ser tu solución ideal si requieres formas en tu app.

En la última versión, la 2.1.0, FormX lucía así:

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 puedes ver, esta implementación dependía muchísimo de MobX, además de que tenía toda la lógica de formas en ella y no podía usar si una app tenía otra solución de manejo de estados.

La necesidad del cambio: apoyar más arquitecturas

Cuando desarrollas una librería open source, los objetivos primarios suelen girar en torno a hacerla accesible para tantos como sea posible y resolver (de hecho) los problemas encarados por la comunidad dev.  Aunque MobX es la solución de manejo de estados que decidimos emplear en las apps de Revelo, sabíamos que había otras soluciones con usuarios cautivos. Con eso en mente y con la meta de expandir la librería para que llegara a más desarrolladores, empezamos a pensar en cómo hacer para que Flutter FormX soportara esas otras soluciones.

Decisiones, decisiones


Ok, ¿pero con cuál comenzar? Estudiamos cuál de las soluciones era la más usada o popular entre la comunidad y llegamos a la conclusión de que Bloc era la mejor opción.

Hay un detalle, valga decir: nunca habíamos usado Bloc en nuestras apps, por lo que tuvimos que conocerla y adaptarla a FormX, algo que hicimos posible.

Iterar implementaciones

Ahora teníamos que pensar en cómo hacer nuestro código para cada nuevo manejo de estados tuviera la misma sintaxis y métodos,  incluso aunque hubiera diferencias sensibles entre ellos.

Fue allí cuando decidimos que, para lograrlo, necesitaríamos transformar FormX en una clase abstracta con los métodos que necesitaríamos para manejar el estado, la actualización y validación de campos de la forma. Asimismo, extrajimos una clase de estado específicamente para conservar el estado de la forma, dado que Bloc usa un estado por separado.

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;
}

Este cambio nos obligó (en el buen sentido) a actualizar nuestra implementación de MobX, bautizándola ahora como FormXMobX.  La implementación era la misma, salvo que ahora era una mezcla que implementaba tanto FormX y FormXState.

Seguro habrás notado que este fue el primer cambio abrupto en nuestra actualización.

Luego de garantizar que nuestra implementación MobX todavía funcionara como se esperaba, nos enfocamos en la implementación de Bloc. En esto surgieron dos clases: FormXCubit y FormXState.

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];
}

Recordemos que esto se hizo basado en nuestro conocimiento reciente de Bloc… y estamos contentos por ello.  Funcionó como se esperaba y pudimos replicar con él nuestra aplicación de Ejemplo.

Sin embargo, queremos hacer código funcional, de calidad y fácil de entender por la inmensa mayoría. Para lograrlo, sentimos que necesitábamos más, así que contactamos a diferentes desarrolladores que trabajan con Bloc y Flutter pidiéndoles ser nuestros “usuarios beta” y darnos retroalimentación directa antes de lanzar todo al público.

Retroalimentación valiosa de otros desarrolladores

Probamos nuestra implementación de Bloc con varios devs de Flutter quienes usan Bloc en su día a día… y su  feedback fue genial. Gracias a ellos, hicimos que funcionara a la perfección. Dichos devs han jugado un rol crucial en el desarrollo de este cambio enorme al provocarnos en el sentido correcto: “¿Por qué no haces ya una versión agnóstica? ¡Ya casi está!”. Agradecimiento especial a Rafaela e Indaband por esto.

Era el empuje que necesitábamos, porque nos sentíamos incómodos con tener métodos con implementaciones repetidas, además de preocuparnos porque pudiera ser difícil que la librería alcanzara la meta que queríamos sin tener nosotros que dedicar largas horas desarrollando implementaciones para cada manejo de estado que hubiera.

Ahí fue donde hizo clic. “Hagamos una implementación básica o estándar de FormX, manejo de estados agnóstico, de modo que la gente pueda usarla con facilidad  en cada app y dejarla administrar sus estados. Nosotros les daremos el resto”.

Con eso, ¡estaba VIVA! Estábamos muy emocionados y comenzamos la reestructuración de FormX. fue intenso, pero definitivamente valió la pena.

Creemos que el takeaway principal es: si tienes la oportunidad de recibir feedback externo, pídelo. Puede ser un punto de inflexión para ti, así como lo fue para nosotros.

El resultado

Después de esta historia, seguro quieres ver cómo quedó, ¿verdad? Lo sabemos.  ¡Aquí va!

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];
}

Nota que mantuvimos una clase de estados, FormXState, la cual conserva el estado actual de FormX y será una de las que se accesará si necesitas algo de forma, como si la forma es válida o no.

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];
}

Con este enfoque, evitaríamos código duplicado al añadir nuevo soporte de manejo de estados, algo cubierto ahora.

Respaldo a los enfoques de manejo de estados

Para mantener el soporte a las soluciones de manejo de estados, decidimos seguir el Patrón de Diseño Adaptador (Adapter Design Pattern), el cual facilitar crear nuevos adaptadores para ellos, que necesitarán la interfaz 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});
}

Con eso, tenemos los siguientes 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;
  }
}

Ambas clases no tienen ninguna lógica de forma en su interior. Meramente usan FormX para hacer todo lo que necesitan y manejar el estado según su solución: MobX con observables y Cubit emitiendo nuevos estados.

Si deseas verlos en acción, conoce nuestra app de ejemplo. Tiene todas las implementaciones, incluyendo las de MobX y Bloc.

Asegurarse de que todo funciona

Después de toda esa refactorización, aún necesitamos comprobar que funciona como se espera y, más importante, garantizar que los cambios futuros no provocarán un cambio indeseado en su comportamiento.

Es por eso que implementamos pruebas unitarias para cada clase y también una prueba de integración centrada en FormX y su respuesta.

Asimismo, hicimos una forma funcional con cada implementación (estándar, MobX y Bloc) para asegurar en pruebas de uso que todas trabajan a la perfección y que no hay diferencias conductuales entre las soluciones de manejo de estados.

¡Y todo esto es para ti!

¿Por qué esto es importante para ti?

FormX ahora es agnóstica en el manejo de estados

Desde este momento, será más rápido implementar nuevos adaptadores para diferentes soluciones de manejo de estados una vez que toda la lógica está dentro de FormX. Los adaptadores de manejo de estados serán solo una cáscara que permitirá a tu estado reaccionar apropiadamente cuando algo cambien en FormX.

¡Lo estándar como lo mejor!

Con el FormX básico o estándar serás capaz de usarlo en apps aunque no emplees soluciones de manejo de estados.  Solo necesitas crear tu propia implementación de estado basada en el FormX estándar y construir tus formas con facilidad.

Más fácil para contribuir

¿Has creado una interfaz usando nuestro FormX estándar para una solución de manejo de estados diferente y ha funcionado de maravilla? ¡Ni lo pienses dos veces! Hagamos juntos esta librería tan completa como sea posible.

Validación de formas

Hemos mantenido los validadores como estaban y todas las versiones de FormX soportan su uso. ¡Despreocúpate! ¡Tienes nuestro apoyo!

Esperamos que ahora tengas una idea más clara sobre cómo ocurrió este proceso, lo que tomó para nosotros aplicar el cambio en FormX y, sobre todo, por qué lo hicimos. Estamos emocionados por los nuevos pasos y realmente esperamos que seas parte de ellos.

¡Hasta pronto!