DEV Community

Cover image for Flutter Form Validation Using Flutter Bloc with Freezed in Domain Driven Design Architecture
Vishnu C Prasad
Vishnu C Prasad

Posted on • Originally published at vishnucprasad.Medium

Flutter Form Validation Using Flutter Bloc with Freezed in Domain Driven Design Architecture

Introduction

In Flutter development, managing form validation is a crucial aspect of building robust applications. When combined with the Flutter Bloc pattern and the Freezed package, it becomes a powerful trio that helps organize, validate, and handle form-related logic efficiently. In this article, we’ll explore how to integrate Flutter Bloc, Freezed annotation, and Domain-Driven Design (DDD) principles to create a clean and maintainable form validation solution in a Flutter application.

NB:

Before diving into the article, I apologize for its length. However, there is no other way to fully explain this topic. At the end of the article, I provide a GitHub repository for reference. Let’s dive in!

Understanding the Components

Flutter Bloc

Flutter Bloc is a state management library that helps developers separate the business logic from the UI layer. It follows the BLoC (Business Logic Component) pattern, where the logic is encapsulated in separate classes and managed by a central authority. This separation enhances code maintainability, testability, and scalability.

Freezed Annotation

Freezed is a code generation library for Dart and Flutter that simplifies the process of creating immutable classes. It utilizes code generation to create boilerplate code for you, reducing redundancy and ensuring immutability, which is crucial for maintaining a predictable state in your application.

Domain-Driven Design (DDD)

DDD is a software design approach that focuses on aligning software design with the business domain. It emphasizes clear separation of concerns, modularization, and a common language between technical and non-technical team members. In the context of form validation, DDD helps organize the validation logic within the domain layer, making it more cohesive and maintainable.

Setting Up the Project

To get started, create a new Flutter project and add the required dependencies to your pubspec.yaml file:

dependencies:
  dartz: ^0.10.1
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3
  freezed_annotation: ^2.4.1

dev_dependencies:
  build_runner: ^2.4.6
  flutter_lints: ^2.0.0
  flutter_test:
    sdk: flutter
  freezed: ^2.4.5
Enter fullscreen mode Exit fullscreen mode

Run flutter pub get to install the dependencies.

Defining the Domain

In DDD, the domain layer holds the business logic of your application. Create a domain directory in your project, and within it, define a file named value_failure.dart:

// domain/value_failure.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'value_failure.freezed.dart';

@freezed
class ValueFailure<T> with _$ValueFailure<T> {
  const factory ValueFailure.empty({
    required T failedValue,
  }) = _Empty<T>;
  const factory ValueFailure.invalid({
    required T failedValue,
  }) = _Invalid<T>;
}
Enter fullscreen mode Exit fullscreen mode

This value_failure.dart file defines the possible validation failures using the freezed annotation.

Within the domain directory define another file named value_errors.dart:

// domain/value_errors.dart

import 'value_failure.dart';

class UnexpectedValueError extends Error {
  final ValueFailure valueFailure;
  UnexpectedValueError(this.valueFailure);

  @override
  String toString() {
    const errorMessage =
        'Encountered a ValueFailure at an unrecoverable point. Terminating.';
    return Error.safeToString('$errorMessage Failure was: $valueFailure');
  }
}
Enter fullscreen mode Exit fullscreen mode

This value_errors.dart file defines the errors that occur during validation.

Within the domain directory define another file named value_object.dart:

// domain/value_object.dart

import 'package:dartz/dartz.dart';
import 'value_errors.dart';
import 'value_failure.dart';

abstract class ValueObject<T> {
  const ValueObject();
  Either<ValueFailure<T>, T> get value;

  bool isValid() => value.isRight();
  bool isInvalid() => value.isLeft();

  T getOrCrash() => value.fold((f) => throw UnexpectedValueError(f), id);

  Either<ValueFailure<dynamic>, Unit> get failureOrUnit {
    return value.fold((l) => left(l), (_) => right(unit));
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;

    return other is ValueObject<T> && other.value == value;
  }

  @override
  int get hashCode => value.hashCode;

  @override
  String toString() => 'ValueObject(value: $value)';
}
Enter fullscreen mode Exit fullscreen mode

This value_object.dart file establishes the base model for validating values.

Within the domain directory define another file named value_validators.dart:

// domain/value_validators.dart

import 'package:dartz/dartz.dart';
import 'value_failure.dart';

Either<ValueFailure<String>, String> validateStringNotEmpty(String input) {
  if (input.isNotEmpty) {
    return right(input);
  }
  return left(ValueFailure.empty(failedValue: input));
}

Either<ValueFailure<String>, String> validateEmailAddress(String input) {
  const emailRegex =
      r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$';
  if (RegExp(emailRegex).hasMatch(input)) {
    return right(input);
  }
  return left(ValueFailure.invalid(failedValue: input));
}

Either<ValueFailure<String>, String> validatePassWord(String input) {
  const passwordRegex =
      r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{6,}$';
  if (RegExp(passwordRegex).hasMatch(input)) {
    return right(input);
  }
  return left(ValueFailure.invalid(failedValue: input));
}
Enter fullscreen mode Exit fullscreen mode

This value_validators.dart file defines functions for validating value objects.

Within the domain directory define another file named value_objects.dart:

// domain/value_objects.dart

import 'package:dartz/dartz.dart';
import 'value_failure.dart';
import 'value_object.dart';
import 'value_validators.dart';

class EmailAddress extends ValueObject<String> {
  @override
  final Either<ValueFailure<String>, String> value;
  factory EmailAddress(String input) {
    return EmailAddress._(
      validateStringNotEmpty(input).flatMap(
        validateEmailAddress,
      ),
    );
  }
  const EmailAddress._(this.value);
}

class Password extends ValueObject<String> {
  @override
  final Either<ValueFailure<String>, String> value;
  factory Password(String input) {
    return Password._(
      validateStringNotEmpty(input).flatMap(
        validatePassWord,
      ),
    );
  }
  const Password._(this.value);
}
Enter fullscreen mode Exit fullscreen mode

This value_objects.dart file defines models that inherit the base model ValueObject for validating values.

Creating credentials model

We are planning to validate a sign-in form, so create a directory named auth within the domain directory.

Within the auth directory define file named credentials.dart:

// domain/auth/credentials.dart

import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../value_failure.dart';
import '../value_objects.dart';

part 'credentials.freezed.dart';

@freezed
class Credentials with _$Credentials {
  const Credentials._();

  const factory Credentials({
    required EmailAddress emailAddress,
    required Password password,
  }) = _Credentials;

  factory Credentials.empty() {
    return Credentials(
      emailAddress: EmailAddress(''),
      password: Password(''),
    );
  }

  Option<ValueFailure<dynamic>> get failureOption => emailAddress.failureOrUnit
      .andThen(password.failureOrUnit)
      .fold((f) => some(f), (_) => none());
}
Enter fullscreen mode Exit fullscreen mode

This credentials.dart file defines the model for managing credentials within the state, using the freezed annotation.

Within the auth directory define another file named auth_failure.dart:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_failure.freezed.dart';

@freezed
class AuthFailure with _$AuthFailure {
  const factory AuthFailure.validationFailure() = _ValidationFailure;
}
Enter fullscreen mode Exit fullscreen mode

The file auth_failure.dart utilizes the freezed annotation to define potential failures that may occur during the authentication process.

Creating the SigninForm Bloc

Now, create a bloc that will handle the form-related logic. In the lib directory, create a folder named application, the application layer holds all the bloc’s. Within the application folder create a sub folder named signin_form and within it define a file named signin_form_state.dart:

// application/signin_form/signin_form_state.dart

part of 'signin_form_bloc.dart';

@freezed
class SigninFormState with _$SigninFormState {
  const factory SigninFormState({
    required bool isSubmitting,
    required bool hidePassword,
    required bool showValidationError,
    required Credentials credentials,
    required Option<Either<AuthFailure, Unit>> failureOrSuccessOption,
  }) = _SigninFormState;

  factory SigninFormState.initial() {
    return SigninFormState(
      isSubmitting: false,
      hidePassword: true,
      showValidationError: false,
      credentials: Credentials.empty(),
      failureOrSuccessOption: none(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Within the signin_form folder define another file named signin_form_event.dart:

part of 'signin_form_bloc.dart';

@freezed
class SigninFormEvent with _$SigninFormEvent {
  const factory SigninFormEvent.emailAddressChanged({
    required String emailAddress,
  }) = _EmailChanged;
  const factory SigninFormEvent.passwordChanged({
    required String password,
  }) = _PasswordChanged;
  const factory SigninFormEvent.obscureTextChanged() = _ObscureTextChanged;
  const factory SigninFormEvent.signinButtonPressed() = _SigninButtonPressed;
}
Enter fullscreen mode Exit fullscreen mode

Within the signin_form folder define another file named signin_form_bloc.dart:

import 'package:dartz/dartz.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/auth/auth_failure.dart';
import '../../domain/auth/credentials.dart';
import '../../domain/core/value_objects.dart';

part 'signin_form_event.dart';
part 'signin_form_state.dart';
part 'signin_form_bloc.freezed.dart';

class SigninFormBloc extends Bloc<SigninFormEvent, SigninFormState> {
  SigninFormBloc() : super(SigninFormState.initial()) {
    on<SigninFormEvent>((event, emit) async {
      await event.map(
        emailAddressChanged: (e) async => emit(state.copyWith(
          credentials: state.credentials.copyWith(
            emailAddress: EmailAddress(e.emailAddress),
          ),
          failureOrSuccessOption: none(),
        )),
        passwordChanged: (e) async => emit(state.copyWith(
          credentials: state.credentials.copyWith(
            password: Password(e.password),
          ),
          failureOrSuccessOption: none(),
        )),
        obscureTextChanged: (_) async => emit(state.copyWith(
          hidePassword: !state.hidePassword,
        )),
        signinButtonPressed: (_) async {
          emit(state.copyWith(
            isSubmitting: true,
            failureOrSuccessOption: none(),
          ));

          await Future.delayed(const Duration(seconds: 1));

          if (state.credentials.failureOption.isNone()) {
            return emit(state.copyWith(
              isSubmitting: false,
              failureOrSuccessOption: some(right(unit)),
            ));
          }

          emit(state.copyWith(
            isSubmitting: false,
            showValidationError: true,
            failureOrSuccessOption: some(left(
              const AuthFailure.validationFailure(),
            )),
          ));
        },
      );
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

This bloc contains the form state and methods to handle changes in email and password fields. The signinButtonPressed event performs the validation logic and updates the state accordingly.

Now simply run the generator

Use the [watch] flag to watch the files’ system for edits and rebuild as necessary.

flutter pub run build_runner watch --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

if you want the generator to run one time and exit, use

flutter pub run build_runner build
Enter fullscreen mode Exit fullscreen mode

Integrating the SigninFormBloc with the UI

Now inside the lib directory create a folder named presentation, the presentation layer holds all the UI elements.

Create the following files in the presentation directory for the UI implementation:

form validation UI sample screenshot

1. presentation/screens/signin_screen.dart

// presentation/screens/signin_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../application/signin_form/signin_form_bloc.dart';
import 'widgets/signin_form.dart';

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => SigninFormBloc(),
      child: BlocListener<SigninFormBloc, SigninFormState>(
        listenWhen: (p, c) =>
            p.failureOrSuccessOption != c.failureOrSuccessOption,
        listener: (context, state) {
          state.failureOrSuccessOption.fold(
            () => null,
            (either) => either.fold(
              (f) {
                // Handle failure
              },
              (_) {
                // Handle success
              },
            ),
          );
        },
        child: Scaffold(
          appBar: AppBar(
            title: const Text('SIGN IN'),
          ),
          body: Center(
            child: SizedBox(
              height: 450,
              width: MediaQuery.of(context).size.width - 32,
              child: const Card(
                child: Padding(
                  padding: EdgeInsets.all(16),
                  child: SigninForm(),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

2. presentation/screens/widgets/signin_form.dart

// presentation/screens/widgets/signin_form.dart

import 'package:flutter/material.dart';
import 'email_input_field.dart';
import 'password_input_field.dart';
import 'submit_button.dart';

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          'Sign in',
          style: Theme.of(context).textTheme.headlineLarge?.copyWith(
                fontWeight: FontWeight.bold,
                color: Colors.deepPurple,
              ),
        ),
        const EmailInputField(),
        const PasswordInputField(),
        const SubmitButton()
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

3. presentation/screens/widgets/email_input_field.dart

// presentation/screens/widgets/email_input_field.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../application/signin_form/signin_form_bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    final controller = TextEditingController();
    return BlocConsumer<SigninFormBloc, SigninFormState>(
      listenWhen: (p, c) => p.showValidationError != c.showValidationError,
      listener: (context, state) {
        controller.text =
            state.credentials.emailAddress.value.getOrElse(() => '');
      },
      builder: (context, state) {
        return TextFormField(
          controller: controller,
          autovalidateMode: state.showValidationError
              ? AutovalidateMode.always
              : AutovalidateMode.disabled,
          decoration: InputDecoration(
            border: OutlineInputBorder(
              borderSide: const BorderSide(color: Colors.deepPurple),
              borderRadius: BorderRadius.circular(10),
            ),
            hintText: 'Type email address',
            labelText: 'Email address',
            prefixIcon: const Icon(
              Icons.email,
              color: Colors.deepPurple,
            ),
          ),
          onChanged: (email) => context
              .read<SigninFormBloc>()
              .add(SigninFormEvent.emailAddressChanged(emailAddress: email)),
          validator: (_) => context
              .read<SigninFormBloc>()
              .state
              .credentials
              .emailAddress
              .value
              .fold(
                (l) => l.map(
                  empty: (_) => "Email address can't be empty.",
                  invalid: (_) => "Invalid email address.",
                ),
                (_) => null,
              ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

4. presentation/screens/widgets/password_input_field.dart

// presentation/screens/widgets/password_input_field.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../application/signin_form/signin_form_bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    final controller = TextEditingController();
    return BlocConsumer<SigninFormBloc, SigninFormState>(
      listenWhen: (p, c) => p.showValidationError != c.showValidationError,
      listener: (context, state) {
        controller.text = state.credentials.password.value.getOrElse(() => '');
      },
      builder: (context, state) {
        return TextFormField(
          controller: controller,
          autovalidateMode: state.showValidationError
              ? AutovalidateMode.always
              : AutovalidateMode.disabled,
          decoration: InputDecoration(
            errorMaxLines: 3,
            border: OutlineInputBorder(
              borderSide: const BorderSide(color: Colors.deepPurple),
              borderRadius: BorderRadius.circular(10),
            ),
            hintText: 'Type password',
            labelText: 'Password',
            prefixIcon: const Icon(
              Icons.lock,
              color: Colors.deepPurple,
            ),
            suffixIcon: IconButton(
              onPressed: () => context
                  .read<SigninFormBloc>()
                  .add(const SigninFormEvent.obscureTextChanged()),
              icon: Icon(
                state.hidePassword
                    ? CupertinoIcons.eye
                    : CupertinoIcons.eye_slash,
                color: Colors.deepPurple,
              ),
            ),
          ),
          obscureText: state.hidePassword,
          onChanged: (password) => context
              .read<SigninFormBloc>()
              .add(SigninFormEvent.passwordChanged(password: password)),
          validator: (_) => context
              .read<SigninFormBloc>()
              .state
              .credentials
              .password
              .value
              .fold(
                (l) => l.map(
                  empty: (_) => "Password can't be empty",
                  invalid: (_) =>
                      'Password must be minimum six characters, at least one uppercase letter, one lowercase letter, one number and one special character',
                ),
                (r) => null,
              ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

5. presentation/screens/widgets/submit_button.dart

// presentation/screens/widgets/submit_button.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../application/signin_form/signin_form_bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<SigninFormBloc, SigninFormState>(
      builder: (context, state) {
        return SizedBox(
          width: double.infinity,
          height: 50,
          child: ElevatedButton(
            style: ButtonStyle(
              backgroundColor: const MaterialStatePropertyAll<Color>(
                Colors.deepPurple,
              ),
              shape: MaterialStatePropertyAll<RoundedRectangleBorder>(
                RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
              ),
            ),
            onPressed: () => context
                .read<SigninFormBloc>()
                .add(const SigninFormEvent.signinButtonPressed()),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                state.isSubmitting
                    ? const SizedBox(
                        height: 16,
                        width: 16,
                        child: CircularProgressIndicator(
                          color: Colors.white,
                        ),
                      )
                    : const Icon(
                        Icons.login,
                        color: Colors.white,
                      ),
                Text(
                  state.isSubmitting ? 'Signing in...' : 'Sign in',
                  style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                      ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We use the BlocProvider to provide the SigninFormBloc to the widget tree and BlocConsumer to listen to and rebuild the UI based on state changes.

Conclusion

In this article, we explored how to leverage Flutter Bloc and Freezed annotation to implement form validation in a Flutter application following Domain-Driven Design principles. This approach enhances code organization, readability, and maintainability. As your application grows, the separation of concerns provided by these tools will become even more valuable, allowing you to easily extend and modify your form validation logic. Consider exploring additional features, such as dependency injection and testing, to further enhance the robustness of your Flutter application.

Here’s a link to a sample project repository for a more concrete demonstration of the topic discussed in this article: Github Repo ➡️

Top comments (0)