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
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>;
}
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');
}
}
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)';
}
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));
}
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);
}
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());
}
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;
}
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(),
);
}
}
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;
}
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(),
)),
));
},
);
});
}
}
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
if you want the generator to run one time and exit, use
flutter pub run build_runner build
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:
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(),
),
),
),
),
),
),
);
}
}
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()
],
);
}
}
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,
),
);
},
);
}
}
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,
),
);
},
);
}
}
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,
),
),
],
),
),
);
},
);
}
}
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)