This is the fourth part of Flutter tutorial series where we are building an Expenses Manager app.
Introduction
In our previous post of the Flutter tutorial series, we created a page to add new category. In this part, we will finish off the creating expense category of the application. We will be implementing the BLoC pattern for this purpose.
At the end, we will have achieved the following:

You can find the entire code for “add_category.dart“, “category_page.dart” and “category_bloc.dart” at the end of this tutorial.
Setup BLoC For Expense Category
BLoC stands for Business Logic Component. The core principle behind the BLoC pattern is to separate business logic code from UI design and interaction. It helps to deliver events/data from network to UI screens and vice-versa.
Learn More About Blocs:
Let’s create a new folder called “blocs” inside the “lib” folder and add a file “category_bloc.dart” in it.
import 'package:expense_manager/db/services/category_service.dart';
class CategoryBloc {
final CategoryServiceBase categoryService;
CategoryBloc(this.categoryService) {
}
}
The CategoryBloc
is just a regular class. It takes an object of CategoryService
class in the constructor which communicates with the database layer.
CategoryBloc
will mainly be responsible for creating new expense category as well as fetching list of categories. All the the data interaction and flow will be done via data streams.
Making Use Of RxDart Package
Although Dart has pretty good support for data streams, the RxDart package enhances Dart’s capabilities. So, let’s add dependency for the package.
rxdart: ^0.22.0
Add a controller that will be used to manage Category stream.
var _createCategoryController = BehaviorSubject<CategoryModel>();
Stream<CategoryModel> get createCategoryStream => _createCategoryController.stream;
Create a method to insert CategoryModel
into the stream.
updateCreateCategory(CategoryModel cat) => _createCategoryController.sink.add(cat);
Next, we will make following changes on the AddCategory
widget:
- Make it into a stateful widget
- Create an instance of
CategoryBloc
- Use
StreamBuilder
to work withCategoryModel
stream
...
class AddCategory extends StatefulWidget {
@override
_AddCategoryState createState() => _AddCategoryState();
}
class _AddCategoryState extends State<AddCategory> {
CategoryBloc _categoryBloc;
@override
void initState() {
super.initState();
_categoryBloc = CategoryBloc(CategoryService());
_categoryBloc.updateCreateCategory(CategoryModel());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Add New Category"),
),
body: Container(
padding: EdgeInsets.all(12.0),
child: StreamBuilder(
stream: _categoryBloc.createCategoryStream,
builder: (ctxt, AsyncSnapshot<CategoryModel> catgorySnap) {
//todo:
},
)),
);
}
...
We have replaced the content of build method from previous post with the StreamBuilder
. The StreamBuilder
listens to stream _categoryBloc.createCategoryStream
.
Working With StreamBuilder In Flutter
StreamBuilder widget is very useful when working with stream of data and observables in Flutter. It rebuilds itself whenever the data stream is changed.
We will update the value of category stream as user enters values for title, description and icon fields.
Learn More About StreamBuilder here: Implement BLoC Pattern With TextField
Input For Title And Description Fields
Previously, the widget for “Title” text field looked like this:
TextField(
decoration: InputDecoration(labelText: "Title"),
),
Now, we will make use of the onChanged
handler for this TextField
so that we can update the category stream.
TextField(
decoration: InputDecoration(labelText: "Title"),
onChanged: (String text) {
if(text == null || text.trim() == "") return;
var category = catgorySnap.data;
var upated = category.rebuild((b) => b ..title = text);
_categoryBloc.updateCreateCategory(upated);
}),
The categorySnap.data
provides the current value for CategoryModel
. This value is then updated and then the model itself is passed to stream controller to be get updated.
Following the same technique for “Description” field as well, we can have:
...
child: StreamBuilder(
stream: _categoryBloc.createCategoryStream,
builder: (ctxt, AsyncSnapshot<CategoryModel> catgorySnap) {
if(!catgorySnap.hasData) return CircularProgressIndicator();
return Column(
children: <Widget>[
TextField(
decoration: InputDecoration(labelText: "Title"),
onChanged: (String text) {
if(text == null || text.trim() == "") return;
var category = catgorySnap.data;
var upated = category.rebuild((b) => b ..title = text);
_categoryBloc.updateCreateCategory(upated);
}),
TextField(
decoration: InputDecoration(labelText: "Description"),
maxLines: 2,
onChanged: (String text) {
if(text == null || text.trim() == "") return;
var category = catgorySnap.data;
var upated = category.rebuild((b) => b ..desc = text);
_categoryBloc.updateCreateCategory(upated);
}),
//todo: set icon
],
);
},
)),
...
Flutter Icon Picker
Now that we have the values for category title and description in place, next we need to get value for selected icon.
For this, we will use the onPressed
handler of IconButton
widget. Once the value is updated, we will also need to make sure we change the selected icon’s color as well.
...
var iconData = ls[index];
return IconButton(
color: category.iconCodePoint == null
? null
: category.iconCodePoint == iconData.codePoint
? Colors.yellowAccent
: null,
onPressed: () {
var upated = category.rebuild((b) => b..iconCodePoint = iconData.codePoint);
_categoryBloc.updateCreateCategory(upated);
},
icon: Icon(
iconData,
)
)
...
Output

As can be seen, we have an icon picker ready for the Flutter app! Now we need to save this in our database.
Saving Created Expense Category
Let’s add a method in the CategoryBloc
to create category.
Future<int> createNewCategory(CategoryModel catgory) async {
return await categoryService.createCategory(catgory);
}
This method calls the categoryService.createCategory
and returns the result.
Now add a button to call this method.
RaisedButton(
child: Text("Create"),
onPressed: catgorySnap.data.title == null ? null : () async {
var createdId = await _categoryBloc.createNewCategory(catgorySnap.data);
if(createdId > 0) {
Navigator.of(context).pop();
}
else {
//show error here...
}
},
),
The “Create” button gets enabled only after the category title is entered by the user. Once category is created, we pop back to previous screen.
Update Category List Items
Previously we had shown a static list of items in the category tab screen. Now, we will update this screen so that we show the created categories from database.
Create a stream of category list in the CategoryBloc
.
var _categoryListController = BehaviorSubject<BuiltList<CategoryModel>>();
Stream<BuiltList<CategoryModel>> get categoryListStream => _categoryListController.stream;
Initialize the category list.
CategoryBloc(this.categoryService) {
_getCategories();
}
_getCategories() {
categoryService.getAllCategories().then((cats) {
_categoryListController.sink.add(cats);
}).catchError((err) {
_categoryListController.sink.addError(err);
});
}
Use StreamBuilder
to get the list of category items in the CategoryPage
.
...
StreamBuilder(
stream: _categoryBloc.categoryListStream,
builder:
(_, AsyncSnapshot<BuiltList<CategoryModel>> categoryListSnap) {
if (!categoryListSnap.hasData) return CircularProgressIndicator();
var lsCategories = categoryListSnap.data;
return Expanded(
child: ListView.builder(
itemCount: lsCategories.length,
...
Complete Code For Each Page
CategoryBloc
import 'dart:async';
import 'package:built_collection/built_collection.dart';
import 'package:expense_manager/db/services/category_service.dart';
import 'package:expense_manager/models/category_model.dart';
import 'package:rxdart/rxdart.dart';
class CategoryBloc {
final CategoryServiceBase categoryService;
CategoryBloc(this.categoryService) {
getCategories();
}
var _createCategoryController = BehaviorSubject<CategoryModel>();
Stream<CategoryModel> get createCategoryStream => _createCategoryController.stream;
updateCreateCategory(CategoryModel cat) => _createCategoryController.sink.add(cat);
var _categoryListController = BehaviorSubject<BuiltList<CategoryModel>>();
Stream<BuiltList<CategoryModel>> get categoryListStream => _categoryListController.stream;
getCategories() {
categoryService.getAllCategories().then((cats) {
_categoryListController.sink.add(cats);
}).catchError((err) {
_categoryListController.sink.addError(err);
});
}
Future<int> createNewCategory(CategoryModel catgory) async {
return await categoryService.createCategory(catgory);
}
dispose() {
_createCategoryController.close();
_categoryListController.close();
}
}
AddCategory
import 'package:expense_manager/blocs/category_bloc.dart';
import 'package:expense_manager/models/category_model.dart';
import 'package:flutter/material.dart';
class AddCategory extends StatefulWidget {
final CategoryBloc categoryBloc;
const AddCategory({Key key, this.categoryBloc}) : super(key: key);
@override
_AddCategoryState createState() => _AddCategoryState();
}
class _AddCategoryState extends State<AddCategory> {
@override
void initState() {
super.initState();
widget.categoryBloc.updateCreateCategory(CategoryModel());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Add New Category"),
),
body: Container(
padding: EdgeInsets.all(12.0),
child: StreamBuilder(
stream: widget.categoryBloc.createCategoryStream,
builder: (ctxt, AsyncSnapshot<CategoryModel> catgorySnap) {
if (!catgorySnap.hasData) return CircularProgressIndicator();
return Column(
children: <Widget>[
TextField(
decoration: InputDecoration(labelText: "Title"),
onChanged: (String text) {
if (text == null || text.trim() == "") return;
var category = catgorySnap.data;
var upated = category.rebuild((b) => b..title = text);
widget.categoryBloc.updateCreateCategory(upated);
}),
TextField(
decoration: InputDecoration(labelText: "Description"),
maxLines: 2,
onChanged: (String text) {
if (text == null || text.trim() == "") return;
var category = catgorySnap.data;
var upated = category.rebuild((b) => b..desc = text);
widget.categoryBloc.updateCreateCategory(upated);
}),
Container(
child: Text("Pick An Icon:"),
margin: EdgeInsets.all(12.0),
),
Expanded(
child: Container(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: _showIconGrid(catgorySnap.data))),
RaisedButton(
child: Text("Create"),
onPressed: catgorySnap.data.title == null ? null : () async {
var createdId = await widget.categoryBloc.createNewCategory(catgorySnap.data);
if(createdId > 0) {
Navigator.of(context).pop();
widget.categoryBloc.getCategories();
}
else {
//show error here...
}
},
),
],
);
},
)),
);
}
_showIconGrid(CategoryModel category) {
var ls = [
Icons.web_asset,
Icons.weekend,
Icons.whatshot,
Icons.widgets,
Icons.wifi,
Icons.wifi_lock,
Icons.wifi_tethering,
Icons.work,
Icons.wrap_text,
Icons.youtube_searched_for,
Icons.zoom_in,
Icons.zoom_out,
Icons.zoom_out_map,
Icons.restaurant_menu,
Icons.restore,
Icons.restore_from_trash,
Icons.restore_page,
Icons.ring_volume,
Icons.room,
Icons.exposure_zero,
Icons.extension,
Icons.face,
Icons.fast_forward,
Icons.fast_rewind,
Icons.fastfood,
Icons.favorite,
Icons.favorite_border,
];
return GridView.count(
crossAxisCount: 8,
children: List.generate(ls.length, (index) {
var iconData = ls[index];
return IconButton(
color: category.iconCodePoint == null
? null
: category.iconCodePoint == iconData.codePoint
? Colors.yellowAccent
: null,
onPressed: () {
var upated = category
.rebuild((b) => b..iconCodePoint = iconData.codePoint);
widget.categoryBloc.updateCreateCategory(upated);
},
icon: Icon(
iconData,
));
}),
);
}
}
CategoryPage
import 'package:built_collection/built_collection.dart';
import 'package:expense_manager/blocs/category_bloc.dart';
import 'package:expense_manager/db/services/category_service.dart';
import 'package:expense_manager/models/category_model.dart';
import 'package:expense_manager/routes/add_category.dart';
import 'package:flutter/material.dart';
class CategoryPage extends StatefulWidget {
@override
_CategoryPageState createState() => _CategoryPageState();
}
class _CategoryPageState extends State<CategoryPage> {
CategoryBloc _categoryBloc;
@override
initState() {
super.initState();
_categoryBloc = CategoryBloc(CategoryService());
}
@override
Widget build(BuildContext context) {
return _getCategoryTab();
}
Widget _getCategoryTab() {
return Column(
children: <Widget>[
Container(
padding: EdgeInsets.all(12.0),
width: 200.0,
child: RaisedButton(
child: Text("Add New"),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => AddCategory(categoryBloc: _categoryBloc,)),
);
},
),
),
StreamBuilder(
stream: _categoryBloc.categoryListStream,
builder:
(_, AsyncSnapshot<BuiltList<CategoryModel>> categoryListSnap) {
if (!categoryListSnap.hasData) return CircularProgressIndicator();
var lsCategories = categoryListSnap.data;
return Expanded(
child: ListView.builder(
itemCount: lsCategories.length,
itemBuilder: (BuildContext ctxt, int index) {
var category = lsCategories[index];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4.0),
border: new Border.all(
width: 1.0,
style: BorderStyle.solid,
color: Colors.white)),
margin: EdgeInsets.all(12.0),
child: ListTile(
onTap: () {},
leading: Icon(
IconData(category.iconCodePoint,
fontFamily: 'MaterialIcons'),
color: Theme.of(context).accentColor,
),
title: Text(
category.title,
style: Theme.of(context)
.textTheme
.body2
.copyWith(color: Theme.of(context).accentColor),
),
subtitle: Text(
category.desc,
),
),
);
},
),
);
},
)
],
);
}
}
You must be logged in to post a comment.