Clean Architecture Using Flutter Bloc
In every software application, we will follow a design pattern to architecture things. Without maintaining a proper architecture, we inevitably encounter numerous bugs and complexities when integrating new features in future development. So to solve these issues we have different types of design patterns in Flutter MVP, MVVM, MVC, Bloc, etc.
Agenda :
Developing a small application using a bloc centered around the concept of school. This application facilitates the creation and updating of multiple schools, with the additional capability to manage students within each school.
Reference web link: https://flutter-end-to-end.web.app/#/home/schools
I believe you not only learn the Bloc pattern from this post but also you will learn how to structure the whole project to use utils, custom widgets, mixins, etc.
Note: Before moving any further I hope you have some knowledge on Widgets, Making HTTP calls, and dart programming language.
What is Bloc Pattern?
Flutter Bloc is a state management tool. It is used to manage state of an application. Bloc internally uses streams to handle the data flow from UI to Bloc and Bloc to UI.
For more info visit the official site https://bloclibrary.dev/#/gettingstarted
Getting Started :
Let’s discuss a little brief about Flutter bloc before diving into the code, below is the flow chart that represents the architecture of the bloc pattern. We will structure our app in this format only
Interaction between UI and Bloc?
Here to manage this we have two primary things involved i.e event and state
Event: An event is a kind of trigger from UI like user interaction or navigating to another screen. Once an event is triggered it informs the bloc to do some operations like fetching data from the server.
State: Once the bloc has the data it will emit data/error in state format to the Bloc widgets.
Bloc Widgets :
The library provides Bloc widgets to handle reactive programming
Bloc Provider/ Multi Bloc Provider :
Bloc Provider will have access for it’s below children. Below is the way of initializing it
If you have mutiple bloc you can use Multi Bloc provider as shown below
Bloc Listener :
Using this widget you can listen to different state that are emitted from the bloc. This widget doesn’t rebuild the view it is just for listening events to show a snackbar, dialogs, navigate or pop the page.
Bloc Builder :
Using Bloc Builder you can rebuild the child widgets based on the state. It has two closures buildWhen and builder.
Based on the state you can write an instruction whether it needs to rebuild or not.
Bloc Consumer :
Bloc Consumer is a premium widget that will have both the features of BlocBuilder and BlocListener
Bloc Selector:
This widget allows to filter the state response as shown below
Using Flutter Bloc in Real-time Project :
In this project, we are going to use the Firebase real-time database to perform CRUD operations using rest APIs.
As discussed before we are going to work on the concept of creating Schools, below are the main features of this application
- Creating a school
- Updating few more details of a school
- Adding and updating students to a school.
In this blog, creating and updating a school will discussed for remaining go through code.
You can find the whole code in the git repo => https://github.com/krishnaji-yedlapalli/flutter_end_to_end
Let’s create the directories in the lib folder
ui: Nothing but a presentation layer, this holds the whole UI logic of the application.
bloc: View model, this will handle the whole business operations and act as a mediator between UI and data.
repository: Represents as a data handler, it will convert the data to our required class models.
models: Represent the structure of data used within the application. Models often include methods for serializing data to formats such as JSON or XML, as well as deserializing.
Dart is a statically typed language, and using models provides type safety when working with data
services: Nothing but data provider, it creates a channel between our application and server, and makes all network calls from here.
widgets: We won’t directly utilize Material/Cupertino components since we’ll need to customize them based on the UX. Any customized components will be placed in this directory..
mixins: In every application we have reusable code, we place that code in the mixin class and will use it throughout the application.
Unlike inheritance, where a subclass can only inherit from one superclass, a class can use multiple mixins.
utils: The utils
folder typically contains code that doesn't belong to any specific feature or domain but provides common functionality that can be used throughout the project. For example constants, enums, etc.
Below are the dependencies we are going to use in the application
Note: Theming is already integrated into this application if you want know more about it go through below blog
UI Screens (Presentation Layer) :
Let’s create a schools directory in the ui folder and inside that create dart files as shown below
In schools.dart we are going to display a list of schools along with a create school floating action button as shown below
class Schools extends StatefulWidget {
const Schools({Key? key}) : super(key: key);
@override
State<Schools> createState() => _SchoolsState();
}
class _SchoolsState extends State<Schools> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Schools'),
),
floatingActionButton: FloatingActionButton.extended(onPressed: () {}, label: Text('Create School'), icon: Icon(Icons.add)),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Registered Schools:', style: Theme.of(context).textTheme.titleMedium),
Expanded(child: _buildRegisteredSchools([])),
],
).screenPadding(),
);
}
Widget _buildRegisteredSchools(List schools) {
if (schools.isEmpty) return const Center(child: Text('No Schools Found, Create a new School', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)));
return SizedBox(
width: DeviceConfiguration.isMobileResolution ? null : MediaQuery.of(context).size.width / 3,
child: ListView.separated(
itemCount: schools.length,
itemBuilder: (context, index) {
var school = schools.elementAt(index);
return ListTile();
},
separatorBuilder: (BuildContext context, int index) => Divider()),
);
}
}
UI outputs will be as shown below
Currently no schools to show, so we need to create a school first. So on tap of create school let’s build a dialog with three inputs School Name, Country, and Location in create_update_school.dart
class CreateOrUpdateSchool extends StatefulWidget {
const CreateOrUpdateSchool({Key? key}) : super(key: key);
@override
State<CreateOrUpdateSchool> createState() => _CreateOrUpdateSchoolState();
}
class _CreateOrUpdateSchoolState extends State<CreateOrUpdateSchool> {
final TextEditingController schoolNameCtrl = TextEditingController();
final TextEditingController locationCtrl = TextEditingController();
static const List<String> countries = ['India', 'USA', 'UK', 'Russia', 'Dubai', 'China', 'Japan'];
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? selectedCountry;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16),
child: Text('Create School', style: Theme.of(context).textTheme.titleMedium),
),
const Divider(),
_buildFrom(),
const Divider(),
_buildButtons()
],
);
}
Widget _buildFrom() {
return Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Wrap(
spacing: 10,
runSpacing: 20,
children: [
TextFormField(controller: schoolNameCtrl, decoration: InputDecoration(label: Text('School Name'), suffixIcon: const Icon(Icons.school), border: const OutlineInputBorder())),
DropdownButtonFormField(
hint: Text('Select Country', style: TextStyle(fontSize: 17, color: Colors.black.withOpacity(0.5))),
items: countries.map((e) => DropdownMenuItem(child: Text(e), value: e)).toList(),
onChanged: (val) {},
value: selectedCountry,
style: const TextStyle(fontWeight: FontWeight.w100, color: Colors.black),
decoration: const InputDecoration(border: OutlineInputBorder()),
),
TextFormField(controller: locationCtrl, decoration: InputDecoration(label: Text('Location Name'), suffixIcon: const Icon(Icons.location_on), border: const OutlineInputBorder())),
],
),
),
);
}
Widget _buildButtons() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Align(
alignment: Alignment.centerRight,
child: Wrap(spacing: 10, alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.end, children: [ElevatedButton(onPressed: () {}, child: Text('Cancel')), ElevatedButton(onPressed: () {}, child: Text('Create'))]),
));
}
}
Invoke the dialog on tap of Create School, we can see below dialog
Up to now we just implemented simple UI, now we need to implement business logic using bloc, before that we need to discuss a little about models.
Models:
Dart is a type restricted language, So communication between UI, bloc, data handler will be in class instance only. We can also use Map but it is not recommended. So create a class SchoolModel as shown below, we will use this object across the application.
Note: The above model is to hold the information. In forwarding discussion. Will discuss more about JSON serialization, setter, and getter in Network handler(Repository) section.
Bloc (View Model):
Let’s create the below three files in the bloc directory
As discussed in earlier, event is to trigger the bloc like on tap of create school and the state provides the results to UI like providing created schools.
Let’s dive into each file and see how we can use them…!!!
school_event.dart:
Let’s create two events createOrUpdateSchool this informs the bloc that the user has created a school and SchoolsDataEvent for fetching created schools
part of 'school_bloc.dart';
@immutable
abstract class SchoolEvent {}
class CreateOrUpdateSchoolEvent extends SchoolEvent {
final SchoolModel school;
final bool isCreateSchool;
CreateOrUpdateSchoolEvent(this.school, {this.isCreateSchool = true});
}
class SchoolsDataEvent extends SchoolEvent {
SchoolsDataEvent();
}
In above createOrUpdateSchool class, there is school property which will hold school information and isCreateSchool flag value hold whether it is to create or update the school.
We can create different events based on the requirement for fetching created schools, created students, etc. Based on the event, bloc will respond. For all these event classes type is SchoolEvent so we created an abstract class.
school_state.dart:
Different states are required here. Based on a state, bloc widgets display the data. For example, need to display schools, first we need to show the loader while fetching data from the server at that moment state is SchoolInfoLoading, once data is fetched we need to display the data which will have a different state i.e SchoolsInfoLoaded. In other cases failures will also be there, if there is server/internet connection issue we need to show user understandable error in UI at that moment we will use SchoolDataError state .
part of 'school_bloc.dart';
enum SchoolDataLoadedType {schools, school, students, student}
abstract class SchoolState extends Equatable{
final SchoolDataLoadedType schoolStateType;
const SchoolState(this.schoolStateType);
}
/// Initial
class SchoolInfoInitial extends SchoolState {
const SchoolInfoInitial(super.schoolStateType);
@override
List<Object?> get props => [];
}
/// For showing loader
class SchoolInfoLoading extends SchoolState {
SchoolInfoLoading(super.schoolStateType);
@override
List<Object?> get props => [super.schoolStateType];
}
/// To show the schools
class SchoolsInfoLoaded extends SchoolState {
final List<SchoolModel> schools;
const SchoolsInfoLoaded(SchoolDataLoadedType schoolStateType, this.schools) : super(schoolStateType);
@override
List<Object?> get props => [super.schoolStateType, schools];
}
/// To show the error
class SchoolDataError extends SchoolState {
final DataErrorStateType errorStateType;
SchoolDataError(super.schoolStateType, this.errorStateType);
@override
List<Object?> get props => [schoolStateType, errorStateType];
}
In above code we declared enum property SchoolDataLoadedType this is used in bloc builder to compare whether the UI is needs to be rendered or not. For whole school concept we are using only one bloc.
In forwarding, will discuss how to implement this state and event in screen.
school_bloc.dart:
Let’s create a SchoolBloc from the below code it extends the Bloc with generic type as SchoolEvent and SchoolState which are created in the above section.
We need to pass the initial state to the bloc constructor therefore bloc widgets will be initiated with the SchoolInfoInitial state. So we are passing SchoolInfoInitial as the state here.
And also we need pass the repository which is known as Data handler, will discuss more about it further discussion
Declare a instance variable schools to store the created schools in that
part 'school_event.dart';
part 'school_state.dart';
class SchoolBloc extends Bloc<SchoolEvent, SchoolState> {
final SchoolRepository repository;
var schools = <SchoolModel>[];
SchoolBloc() : super(SchoolInfoInitial(SchoolDataLoadedType.schools)) {
on<SchoolsDataEvent>(fetchSchools);
on<CreateOrEditSchoolEvent>(createOrUpdateSchool);
}
/// Fetch created schools
Future<void> fetchSchools(SchoolsDataEvent schoolEvent, Emitter<SchoolState> emit) async {
try {
} catch (e, s) {
}
}
/// Create or update a school
Future<void> createOrUpdateSchool(CreateOrEditSchoolEvent schoolEvent, Emitter<SchoolState> emit) async {
try {
} catch (e, s) {
}
}
}
To use this bloc, first we need to initialize it as shown below
Now we can access the SchoolBloc in its decendent children .
Let’s fetch existing schools using the bloc. In UI sections(which was discussed earlier) we created schools.dart file to display the schools, in that add SchoolsDataEvent to the bloc as shown below
Now this will triggers the loadSchools methods in bloc by matching event type SchoolsDataEvent and again bloc will request the repository(Data Handler)for schools as shown below
In network handler section we will discuss about the repository.
repository.fetchSchools() method provides existing schools from network provider, now we need to emit this data to UI using emit property as shown below
In the above code we are emitting the state in three places,
- The first emit is to tell the bloc widget to display the loader.
- The second emit is to emit schools from server response.
- Third emit is to emit an error to the bloc widget.
Any one of the state will be emitted to the bloc widget, but to handle this we need to implement the bloc widget in our UI screen(schools.dart).
We have different types of bloc widgets based on the requirement we will use it. Here BlocBuilder is required to display the schools, So we used it.
In buildWhen callback, we are comparing whether schoolStateType is Schools or not if it Schools then only we are returning a true value and builder re render the BlocBuilder
The initial state is SchoolInfoLoading so it executes the circular loader, once data is loaded in bloc it emits SchoolsInfoLoaded it executes the _buildRegisteredSchools output will be as shown below
Let’s create the school in the same way
On tap of create school button invoke the createOrUpdateSchool method in the bloc by passing CreateOrEditSchoolEvent to the bloc. We can pass the event by calling the add method in the bloc as shown below.
Using context we can access bloc properties read, watch ..
if it was create we are passing uuid(Universally Unique IDentifier) as id or else passing existing school id to update the same school
Now in the bloc createOrUpdateSchool method invoked and it has school data, we need to pass this data to the data handler(Repository), which will request the server to create data.
/// Create or update a school
Future<void> createOrUpdateSchool(
CreateOrUpdateSchoolEvent event, Emitter<SchoolState> emit) async {
const schoolState = SchoolDataLoadedType.schools;
try {
navigatorKey.currentContext?.loaderOverlay.show();
var createdOrUpdatedSchool = await repository.createOrEditSchool(event.school);
/// cloning object
schools = List.from(schools);
if (!event.isCreateSchool) {
var index =
schools.indexWhere((school) => school.id == event.school.id);
if (index != -1) {
schools[index] = createdOrUpdatedSchool;
}
} else {
schools.add(createdOrUpdatedSchool);
}
emit(SchoolsInfoLoaded(schoolState, schools));
} catch (e, s) {
/// Handle the error
} finally {
navigatorKey.currentContext?.loaderOverlay.hide();
}
}
Once school is successfully created or updated it will add or update to school array list as show in above logic
From above code, you can find navigatorKey property it is a global key declared in GoRouter, this whole project uses GoRouter for navigating between the pages. From this key, we can get the current context.
So we used to show the loader by passing this context to the loader_overlay package as shown below
It has two options show and hide, for displaying and hiding the loader.
Repository (Data Handler):
Repository manages request preparation and execution, as well as deserializing response data into class instances representing structured data.
Here we can set the endpoint, method type, and converting the class instance into Map(serialising) for preparing body/query parameters of a request.
Response will be converted to model classes(structured data)using JSON and serialization concept in Flutter.
Let’s create a school_repository.dart file in the repository directory and create an abstract class SchoolRepo and class SchoolRepository
abstract class SchoolRepo {
Future<List<SchoolModel>> fetchSchools();
Future<SchoolModel> createOrEditSchool(SchoolModel school);
}
Implement this abstract class into the SchoolRepository
class SchoolRepository with BaseService implements SchoolRepo {
@override
Future<List<SchoolModel>> fetchSchools() async {
List<SchoolModel> schools = <SchoolModel>[];
var response = await makeRequest(url: '${Urls.schools}.json');
if(response is Map) {
schools = response.entries.map<SchoolModel>((json) => SchoolModel.fromJson(json.value)).toList();
}
return schools;
}
@override
Future<SchoolModel> createOrEditSchool(SchoolModel school) async {
Map<String, dynamic> body = {school.id : school.toJson()};
var response = await makeRequest(url: '${Urls.schools}.json', body: body, method: RequestType.patch);
if(response != null && response is Map && response.keys.isNotEmpty) {
school = SchoolModel.fromJson(response[response.keys.first]);
}
return school;
}
}
Let’s add a mixin BaseService, it is the class that will give you access to request the server. We will discuss this in the data provider section.
In the models section (which was discussed earlier), we created a class SchoolModel. The response from the data provider will be in Map or List<Map> so we need to convert it into the required SchoolModel but our SchoolModel doesn’t have those abilities to do that.
We can achieve this by using JSON serialization , let's modify our SchoolModel
import 'package:json_annotation/json_annotation.dart';
part 'school_model.g.dart';
@JsonSerializable()
class SchoolModel {
SchoolModel(this.schoolName, this.country, this.location, this.id);
@JsonKey(required: true)
final String schoolName;
final String country;
final String location;
final String id;
factory SchoolModel.fromJson(Map<String, dynamic> json) =>
_$SchoolModelFromJson(json);
Map<String, dynamic> toJson() => _$SchoolModelToJson(this);
}
Run the below command, to generate g dart file
dart run build_runner build — delete-conflicting-outputs
Now you will have access to toJson and fromJson methods
Here will be in Map format, So response will be iterated using entries
Using from fromJson convert the data into SchoolModel. if there is any exception directly error will be handled in bloc itself.
Base Service (Data Provider) :
Base service is responsible for sending the request to the server.
Authorisation tokens, baser urls, and others headers will be added here.
We have different packages to make network calls like http, dio, etc. In this project we are using Dio. Below was the code how requests and response will be handled.
import 'package:dio/dio.dart';
import 'dart:async';
mixin BaseService {
Future<dynamic> makeRequest<T>(
{required String url,
String? baseUrl,
dynamic body,
String? contentType,
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
RequestType method = RequestType.get,
Map<String, dynamic> extras = const {}}) async {
dio.options.baseUrl = baseUrl ?? Urls.baseUrl;
dio.options.extra.addAll(extras);
if (headers != null) dio.options.headers.addAll(headers);
Response response;
switch (method) {
case RequestType.get:
if (queryParameters != null && queryParameters.isNotEmpty) {
response = await dio.get(
url,
queryParameters: queryParameters,
);
return response.data;
}
response = await dio.get(url);
return response.data;
case RequestType.put:
response =
await dio.put(url, queryParameters: queryParameters, data: body);
return response.data;
case RequestType.post:
response = await dio.post(
url,
queryParameters: queryParameters,
data: body,
);
return response.data;
case RequestType.delete:
response =
await dio.delete(url, queryParameters: queryParameters, data: body);
return response.data;
case RequestType.patch:
response = await dio.patch(
url,
queryParameters: queryParameters,
data: body,
);
}
return response.data;
}
}
Based on the method switch case will be executed. We can also overrides the baseurl, add extra headers …
widgets:
In widgets folder we can add our customised widgets and use it globally, for example in this application we have text field and dropdown every where for creating school, students, etc. So we created custom text field and generic dropdown in required theme as shown below.
Custom Text Field :
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatelessWidget {
const CustomTextField({Key? key, required this.controller, required this.label, this.suffixIcon, this.validator, this.inputFormatter}) : super(key: key);
final TextEditingController controller;
final String label;
final Icon? suffixIcon;
final String? Function(String?)? validator;
final List<TextInputFormatter>? inputFormatter;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: outlineDecoration(),
validator: validator,
inputFormatters: inputFormatter,
);
}
InputDecoration outlineDecoration() {
return InputDecoration(
label: Text(label),
suffixIcon: suffixIcon,
border: const OutlineInputBorder()
);
}
}
Custom Drop down :
import 'package:flutter/material.dart';
class CustomDropDown<T> extends StatelessWidget {
const CustomDropDown({Key? key, required this.value, required this.items, required this.onChanged, this.hint, this.validator}) : super(key: key);
final T? value;
final List<DropdownMenuItem<T>> items;
final ValueChanged<T?> onChanged;
final String? hint;
final String? Function(T?)? validator;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
hint: hint != null ? Text(hint!) : null,
items: items, onChanged: onChanged,
value: value,
validator: validator,
style: const TextStyle(fontWeight: FontWeight.w100, color: Colors.black),
decoration: const InputDecoration(
border: OutlineInputBorder()
),
);
}
}
mixins:
Mixins are a way to reuse code in multiple classes, In inheritance concept subclass can inherit only one super class but in mixins we inherit multiple classes using with keyword
In every application so many reusable helper methods will be there, we can place all those methods in mixins and use them in all over the application.
In our application we can make few methods as common for example dailogs, empty message, loaders and validators..
Dialog mixin :
import 'package:flutter/material.dart';
mixin CustomDialogs {
void adaptiveDialog(BuildContext context, Widget content) {
showAdaptiveDialog(
context: context,
builder: (context) {
return Dialog(
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
child: content,
));
});
}
Widget dialogWithButtons(
{required String title,
required Widget content,
required List<String> actions,
required ValueChanged<int> callBack}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16),
child: Text(title),
),
const Divider(),
content,
const Divider(),
_buildButtons(actions, callBack)
],
);
}
Widget _buildButtons(List<String> actions, ValueChanged<int> callBack) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 10,
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.end,
children: List.generate(
actions.length,
(index) => ElevatedButton(
onPressed: () => callBack(index),
child: Text(actions.elementAt(index)))),
),
));
}
}
Loaders:
import 'package:flutter/material.dart';
mixin Loaders {
Widget circularLoader() {
return const Center(child: CircularProgressIndicator());
}
}
utils:
utils folder typically contains code that doesn't belong to any specific feature or domain but provides common functionality that can be used throughout the project. For declaring constants, enums, device configuration, etc. We will use utils folder
Git Reference: https://github.com/krishnaji-yedlapalli/flutter_end_to_end
Web Page Reference: https://flutter-end-to-end.web.app/
I trust that this article has been beneficial to you in the undestanding of bloc pattern.
Thanks for Reading 😊 !!!!!