Привет, коллеги!

Хочу поделиться своим опытом работы с формами во Flutter. Каждый из нас сталкивался с задачей создания сложных форм и хочу рассказать о подходе с использованием нового пакета form_model.

Почему form_model?

  1. Он помогает отделить логику валидации от UI, что значительно упрощает поддержку кода.

  2. Предоставляет гибкую систему валидации с возможностью создания кастомных валидаторов.

  3. Хорошо интегрируется с BLoC (хотя, думаю, и с другими подходами к управлению состоянием тоже будет работать).

  4. Справляется со сложными структурами форм без особых усилий.

Для начала добавим следующие зависимости в pubspec.yaml

  • form_model

  • flutter_bloc

  • freezed

Кастомные объекты

Для демонстрации работы со сложными типами данных создадим простой класс Address:

@freezed
class Address with _$Address {
  const factory Address({
    required String street,
    required String city,
    required String country,
  }) = _Address;
}

State Management

Для управления c состоянием я обычно использую подход с единым состоянием (single-state approach). Вот как выглядит мой класс StateStatus:

@freezed
class StateStatus with _$StateStatus {
  const factory StateStatus() = PureStatus;
  const factory StateStatus.loading() = LoadingStatus;
  const factory StateStatus.success([dynamic data]) = SuccessStatus;
  const factory StateStatus.error([String? message]) = ErrorStatus;
}

Это позволяет представить четыре различных состояния формы: исходное, загрузка, успех и ошибка.


Теперь можем переходить к основному, к реализации самого блока

SignUpState

@freezed
class SignUpState with _$SignUpState {
  const factory SignUpState({
    @Default(StateStatus()) StateStatus status,
    @Default(FormModel<String>(validators: [
      RequiredValidator(),
      EmailValidator(),
    ]))
    FormModel email,
    @Default(FormModel<String>(validators: [
      RequiredValidator(),
      PasswordLengthValidator(minLength: 8),
      PasswordLowercaseValidator(),
      PasswordUppercaseValidator(),
      PasswordSpecialCharValidator(),
    ]))
    FormModel password,
    @Default(FormModel<String>(validators: [
      RequiredValidator(),
      StringConfirmPasswordMatchValidator(),
    ]))
    FormModel confirmPassword,
    @Default(FormModel<String>(validators: [
      RequiredValidator(),
      StringMinLengthValidator(minLength: 6),
      CustomValidator(validator: _validateUsername),
    ]))
    FormModel<String> username,
    @Default(FormModel<Address>(validators: [
      RequiredValidator(),
      CustomValidator(validator: _validateStreet),
      CustomValidator(validator: _validateCity),
      CustomValidator(validator: _validateCountry),
    ]))
    FormModel address,
    @Default(FormModel<bool>(validators: [
      BoolAgreeToTermsAndConditionsValidator(),
    ]))
    FormModel<bool> agreeToTerms,
  }) = _SignUpState;
}

String? _validateUsername(String? value) {
  if (value == null) return null;
  if (!value.startsWith('@')) {
    return 'Username should start with @';
  }
  return null;
}

String? _validateStreet(Address? value) {
  if (value == null) return null;
  if (value.street.isEmpty) {
    return 'Street is required';
  }
  return null;
}

String? _validateCity(Address? value) {
  if (value == null) return null;
  if (value.city.isEmpty) {
    return 'City is required';
  }
  return null;
}

String? _validateCountry(Address? value) {
  if (value == null) return null;
  if (value.country.isEmpty) {
    return 'Country is required';
  }
  return null;
}

Здесь определяем FormModel для каждого поля формы. Интересный момент: form_model позволяет комбинировать валидаторы, что дает возможность создавать сложные правила валидации, оставаясь при этом в рамках принципа единой ответственности.

Например, для пароля используется несколько валидаторов.

Каждый валидатор отвечает только за одну проверку, что упрощает тестирование и повторное использование кода.

SignUpEvent

@freezed
class SignUpEvent with _$SignUpEvent {
  const factory SignUpEvent.emailChanged(String value) = _EmailChanged;
  const factory SignUpEvent.passwordChanged(String value) = _PasswordChanged;
  const factory SignUpEvent.confirmPasswordChanged(String value) = _ConfirmPasswordChanged;
  const factory SignUpEvent.usernameChanged(String value) = _UsernameChanged;
  const factory SignUpEvent.addressChanged(String value) = _AddressChanged;
  const factory SignUpEvent.agreeToTermsChanged(bool value) = _AgreeToTermsChanged;
  const factory SignUpEvent.submitted() = _Submitted;
}

SignUpBloc

class SignUpBloc extends Bloc<SignUpEvent, SignUpState> {
  SignUpBloc() : super(const SignUpState()) {
    on<_EmailChanged>(_onEmailChanged);
    on<_PasswordChanged>(_onPasswordChanged);
    on<_ConfirmPasswordChanged>(_onConfirmPasswordChanged);
    on<_UsernameChanged>(_onUsernameChanged);
    on<_AddressChanged>(_onAddressChanged);
    on<_AgreeToTermsChanged>(_onAgreeToTermsChanged);
    on<_Submitted>(_onSubmitted);
  }

  void _onEmailChanged(_EmailChanged event, Emitter<SignUpState> emit) {
    emit(state.copyWith(email: state.email.setValue(event.value)));
  }

  void _onPasswordChanged(_PasswordChanged event, Emitter<SignUpState> emit) {
    emit(state.copyWith(
      password: state.password.setValue(event.value),
      confirmPassword: state.confirmPassword.replaceValidator(
        predicate: (validator) => validator is StringConfirmPasswordMatchValidator,
        newValidator: StringConfirmPasswordMatchValidator(matchingValue: event.value),
      ),
    ));
  }

  void _onConfirmPasswordChanged(_ConfirmPasswordChanged event, Emitter<SignUpState> emit) {
    emit(state.copyWith(confirmPassword: state.confirmPassword.setValue(event.value)));
  }

  void _onUsernameChanged(_UsernameChanged event, Emitter<SignUpState> emit) {
    emit(state.copyWith(username: state.username.setValue(event.value)));
  }

  void _onAddressChanged(_AddressChanged event, Emitter<SignUpState> emit) {
    final parts = event.value
        .split(',')
        .map(
          (e) => e.trim(),
        )
        .toList();
    final address =
        Address(street: parts[0], city: parts.length > 1 ? parts[1] : '', country: parts.length > 2 ? parts[2] : '');
    emit(state.copyWith(address: state.address.setValue(address)));
  }

  void _onAgreeToTermsChanged(_AgreeToTermsChanged event, Emitter<SignUpState> emit) {
    emit(state.copyWith(agreeToTerms: state.agreeToTerms.setValue(event.value)));
  }

  void _onSubmitted(_Submitted event, Emitter<SignUpState> emit) async {
    emit(state.copyWith(
      email: state.email.validate(),
      password: state.password.validate(),
      confirmPassword: state.confirmPassword.validate(),
      username: state.username.validate(),
      address: state.address.validate(),
      agreeToTerms: state.agreeToTerms.validate(),
    ));

    if (areAllFormModelsValid([
      state.email,
      state.password,
      state.confirmPassword,
      state.username,
      state.address,
      state.agreeToTerms,
    ])) {
      emit(state.copyWith(status: const LoadingStatus()));

      //do some logic here
      await Future.delayed(const Duration(seconds: 2));

      emit(state.copyWith(status: const SuccessStatus()));
    }
  }
}

Здесь есть несколько интересных моментов:

  1. Каждый FormModel возвращает новый экземпляр при изменении, что обеспечивает иммутабельность.

  2. При изменении пароля обновляем также валидатор поля подтверждения пароля.

  3. Для адреса разбиваем входную строку на части, создавая новый объект Address.

UI

class SignUpPage extends StatelessWidget {
  const SignUpPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => SignUpBloc(),
      child: Builder(
        builder: (context) {
          final bloc = context.read<SignUpBloc>();
          return BlocConsumer<SignUpBloc, SignUpState>(
            listener: (context, state) {
              // do something on success status
            },
            builder: (BuildContext context, SignUpState state) {
              return Scaffold(
                body: Padding(
                  padding: const EdgeInsets.all(20),
                  child: SingleChildScrollView(
                    child: Column(
                      children: [
                        const SizedBox(height: 40),
                        TextField(
                          onChanged: (value) => bloc.add(SignUpEvent.emailChanged(value)),
                          decoration: InputDecoration(
                            labelText: 'Email',
                            errorText: state.email.error?.translatedMessage,
                          ),
                        ),
                        const SizedBox(height: 16),
                        TextField(
                          onChanged: (value) => bloc.add(SignUpEvent.passwordChanged(value)),
                          decoration: InputDecoration(
                            labelText: 'Password',
                            errorText: state.password.error?.translatedMessage,
                          ),
                          obscureText: true,
                        ),
                        const SizedBox(height: 16),
                        TextField(
                          onChanged: (value) => bloc.add(SignUpEvent.confirmPasswordChanged(value)),
                          decoration: InputDecoration(
                            labelText: 'Confirm Password',
                            errorText: state.confirmPassword.error?.translatedMessage,
                          ),
                          obscureText: true,
                        ),
                        const SizedBox(height: 16),
                        TextField(
                          onChanged: (value) => bloc.add(SignUpEvent.usernameChanged(value)),
                          decoration: InputDecoration(
                            labelText: 'Username @',
                            errorText: state.username.error?.translatedMessage,
                          ),
                        ),
                        const SizedBox(height: 16),
                        TextField(
                          onChanged: (value) => bloc.add(SignUpEvent.addressChanged(value)),
                          decoration: InputDecoration(
                            labelText: 'Address (street, city, country)',
                            errorText: state.address.error?.translatedMessage,
                          ),
                        ),
                        const SizedBox(height: 16),
                        CheckboxListTile(
                          value: state.agreeToTerms.value ?? false,
                          onChanged: (value) => bloc.add(
                            SignUpEvent.agreeToTermsChanged(value ?? false),
                          ),
                        ),
                        if (state.agreeToTerms.error != null)
                          Text(
                            state.agreeToTerms.error!.translatedMessage ?? '',
                            style: TextStyle(color: Theme.of(context).colorScheme.error),
                          ),
                        const SizedBox(height: 32),
                        ElevatedButton(
                          onPressed: () => bloc.add(const SignUpEvent.submitted()),
                          child: state.status is LoadingStatus
                              ? const SizedBox(
                                  width: 20,
                                  height: 20,
                                  child: CircularProgressIndicator(),
                                )
                              : const Text('Submit'),
                        )
                      ],
                    ),
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Запускаем валидацию только при отправке формы, а не при каждом вводе пользователя. Мне кажется, это обеспечивает лучший UX, но, конечно, это дело вкуса.


Заключение

Этот подход позволит создать довольно сложную форму, сохранив при этом код чистым и легко поддерживаемым. Приятным бонусом является встроенная поддержка локализации в form_model. Это значительно упрощает процесс добавления переводов для сообщений об ошибках валидации.

Конечно, это не единственный способ работы с формами во Flutter, но он весьма удобный. Комбинация form_model и BLoC обеспечит гибкость в валидации и управлении состоянием, а также облегчит интернационализацию.

Статья на английском.

Буду рад услышать ваше мнение и опыт работы с формами во Flutter. Какие подходы используете вы? Сталкивались ли вы с проблемами локализации валидационных сообщений, и как их решали?

Комментарии (0)