Flutter Tutorial: Building An Expense Manager App – 4

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:

DecisionMentor app
Add Category Complete
Add Category Complete

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 with CategoryModel 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

Category Icon Selection
Category Icon Selection

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,
                      ),
                    ),
                  );
                },
              ),
            );
          },
        )
      ],
    );
  }
}

Link To Other Parts Of This Flutter Tutorial