Serializable Models And Offline Database In Flutter

This is the second part of Expenses Manager app in Flutter tutorial series. We will learn to use serializable models and offline database in Flutter apps.

Introduction

In the previous post, we started out building the Expense Manager app. In this post, we will continue the development where we left off. We will add features to manage expense categories in this part of the Flutter tutorial.

At the end of this Flutter tutorial, we will have the following in our app:

DecisionMentor app
  • Create built value model for category
  • View list of category items
  • Setup offline database for storing data
  • Create a service layer to access database

Let’s get started!

Creating Serializable Model For Expense Category

Every app needs data models. For our app we start with a category model which will be a built value type model. A built value type model has many benefits over reference type such as immutability and easy json serialization.

Read more about Built Value: Introduction To Built Value Library

Add dependency for built_value and built_collection packages.

dependencies:
  flutter:
    sdk: flutter
  path_provider: ^1.1.0
  sqflite: ^1.1.5
  built_value: ^6.1.6
  built_collection: ^4.1.0

Also add build_runner and built_value_generator as dev dependencies.

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.0.0
  built_value_generator: ^6.1.6

Next, we will create a category model and a built value serializer.

Create a folder called “models” inside the “lib” folder and add a file “category_model.dart“.

import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';

part 'category_model.g.dart';

abstract class CategoryModel
    implements Built<CategoryModel, CategoryModelBuilder> {
  CategoryModel._();
  factory CategoryModel([updates(CategoryModelBuilder b)]) = _$CategoryModel;
  static Serializer<CategoryModel> get serializer => _$categoryModelSerializer;

  @nullable
  int get id;
  @nullable
  String get title;
  @nullable
  String get desc;
  @nullable
  int get iconCodePoint;
}

We are marking everything nullable for now. Next add “serializers.dart“.

Read More: JSON Serialization In Flutter.

import 'package:built_collection/built_collection.dart';
import 'package:built_value/serializer.dart';
import 'package:built_value/standard_json_plugin.dart';
import 'package:expense_manager/models/category_model.dart';

part 'serializers.g.dart';

@SerializersFor(const [
  CategoryModel
])

final Serializers serializers = (_$serializers.toBuilder()
      ..addPlugin(StandardJsonPlugin()))
      .build();

Both the category_model and serilizers are partial files. We will auto generate the remaining part of these files using the build runner command:

flutter packages pub run build_runner build

This command generates the remaining part of the built value classes in the models folder.

Our category model is ready.

Initialize And View Category List

Now that we have a working model, let’s put it into action. Inside the _HomePageState class, let’s first initialize a list of categories.

List<CategoryModel> _lsCateogies = List<CategoryModel>();

  @override
  void initState() {
    super.initState();
    _tabController = new TabController(vsync: this, length: _tabs.length);

    _initCategories();
  }

  _initCategories() {
    var cat1 = CategoryModel().rebuild((b) => b
      ..id = 0
      ..title = "Home Utils"
      ..desc = "Home utility related expenses"
      ..iconCodePoint = Icons.home.codePoint);

    _lsCateogies.add(cat1);

    var cat2 = CategoryModel().rebuild((b) => b
      ..id = 0
      ..title = "Grocery"
      ..desc = "Grocery related expenses"
      ..iconCodePoint = Icons.local_grocery_store.codePoint);

    _lsCateogies.add(cat2);

    var cat3 = CategoryModel().rebuild((b) => b
      ..id = 0
      ..title = "Food"
      ..desc = "Food related expenses"
      ..iconCodePoint = Icons.fastfood.codePoint);

    _lsCateogies.add(cat3);

    var cat4 = CategoryModel().rebuild((b) => b
      ..id = 0
      ..title = "Auto"
      ..desc = "Car/Bike related expenses"
      ..iconCodePoint = Icons.directions_bike.codePoint);

    _lsCateogies.add(cat4);
  }

We have added 4 categories in a list so far. Next, we will create a ListView to show these items.

Widget _getCategoryTab() {
    return ListView.builder(
      itemCount: _lsCateogies.length,
      itemBuilder: (BuildContext ctxt, int index) {
        var category = _lsCateogies[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, ),
          ),
        );
      },
    );
  }

The _getCategoryTab function creates a list based on the items available in the _lsCategories. When you use this list for the category tab, you can see the view in action.

...
TabBarView(
          controller: _tabController,
          children: <Widget>[
            Center(
                child: Text(
              "Home",
              style: Theme.of(context).textTheme.display1,
            )),
            _getCategoryTab(),
...
A Category List In ListView
A Category List In ListView

That was quite simple. But this is just a static list of items. In a real application, we need to fetch data from a persistent database.

Setup Offline Database In Flutter

In this Flutter tutorial, we will be storing all our data in an offline database. For this purpose, we will make use of the SQLite database engine.

Get SQLite Plugin For Flutter

We will be using the “sqflite” plugin, which is a Flutter plugin for SQLite database engine. To use this plugin, we need to add dependency to it in the pubspec.yaml file.

We will also add a dependency for another plugin “path_provider” which can help us with local file system.

...
dependencies:
  flutter:
    sdk: flutter
  path_provider: ^1.1.0
  sqflite: ^1.1.5
...

Usually, when you save the pubspec.yaml file, the plugin automatically gets downloaded. If it doesn’t, you can install the package by calling the following command from command line:

flutter pub get

Setup Offline DB Provider For SQLite

We have already covered setting up a SQLite database in Flutter app in a previous post. So, here we will jump directly into implementation without much description.

Basically, we will have a database provider, database migration scripts and later a database access service.

Create a folder “db” inside the “lib” folder and another folder “migrations” inside the “db“.

Inside the “migrations” folder, add a file called init_db.dart.

// lib\db\migrations\init_db.dart

const String initDbScript = """
  CREATE TABLE Category (
      id INTEGER PRIMARY KEY,
      title TEXT,
      desc TEXT,
      iconCodePoint INTEGER
    );
  """;

Here, we created a SQL script for category table. The category table has a title, a description and icon code point to create IconData.

Next, create another file “db_script.dart” which will hold a list of all database migration scripts.

// lib\db\migrations\db_script.dart

import 'package:expense_manager/db/migrations/init_db.dart';

class DbMigrator {
  static final Map<int, String> migrations = {
    1: initDbScript,
  };
}

Next, create another file “offline_db_provider.dart” inside the db folder.

// lib\db\offline_db_provider.dart

import 'dart:io';
import 'dart:math';

import 'package:expense_manager/db/migrations/db_script.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';

class OfflineDbProvider {
  OfflineDbProvider._();

  //We use the singleton pattern to ensure that
  //we have only one class instance and provide a global point access to it
  static final OfflineDbProvider provider = OfflineDbProvider._();

  static Database _database;

  Future<Database> get database async =>
      _database == null ? await initDB() : _database;

  initDB() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _dbName);

    var maxMigratedDbVersion = DbMigrator.migrations.keys.reduce(max);    

    _database = await openDatabase(
      path,
      version: maxMigratedDbVersion,
      onOpen: (db) {},
      onCreate: (Database db, int _) async {
        DbMigrator.migrations.keys.toList()
        ..sort()
        ..forEach((k) async {
          var script = DbMigrator.migrations[k];
          await db.execute(script);
        });
      },
      onUpgrade: (Database db, int _, int __) async {
        var curdDbVersion = await getCurrentDbVersion(db);

        var upgradeScripts = new Map.fromIterable(
          DbMigrator.migrations.keys.where((k) => k > curdDbVersion), 
          key: (k) => k, value: (k) => DbMigrator.migrations[k]
        );

        if(upgradeScripts.length == 0) return;

        upgradeScripts.keys.toList()
        ..sort()
        ..forEach((k) async {
          var script = upgradeScripts[k];
          await db.execute(script);
        });

        _upgradeDbVersion(db, maxMigratedDbVersion);
      }
    );
    return _database;
  }

  _upgradeDbVersion(Database db, int version) async {
    await db.rawQuery("pragma user_version = $version;");
  }

  Future<int> getCurrentDbVersion(Database db) async {
    var res = await db.rawQuery('PRAGMA user_version;', null);
    var version = res[0]["user_version"].toString();
    return int.parse(version);
  }

  dropDB() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _dbName);
    return await deleteDatabase(path);
  }

  String _dbName = "ExpenseManager.db";
}

The OfflineDbProvider is responsible for creating and updating the offline database. For adding migrations on the database, now all we have to do is add scripts via db_script.dart file.

Finally, to initialize the database when the application first runs, update the main.dart file’s main method as below:

void main() {
  OfflineDbProvider.provider.initDB();
  runApp(MyApp());
}

Building A Service Layer For SQLite Database In Flutter

Create a new folder “services” inside the “db” folder and add a file “category_service.dart“. This file will contain an abstract class which will act as an interface and an implementation for the abstract class.

The class CategoryService implements the abstract class CategoryServiceBase.

import 'package:built_collection/built_collection.dart';
import 'package:expense_manager/models/category_model.dart';

abstract class CategoryServiceBase {
  Future<BuiltList<CategoryModel>> getAllCategories();
  Future<int> createCategory(CategoryModel category);
}

class CategoryService implements CategoryServiceBase {
  @override
  Future<BuiltList<CategoryModel>> getAllCategories() {
    // TODO: implement getAllCategories
    return null;
  }

  @override
  Future<int> createCategory(CategoryModel category) {
    // TODO: implement createCategory
    return null;
  }
}

Right now we only have two methods to interact with the SQLite database:

  • create a new row of category item
  • fetch the list of category items

Notice that we are working with BuiltList instead of a regular list.

Implementing the abstract class, the code for creating a new category will look like this:

 @override
  Future<int> createCategory(CategoryModel category) async {
    //check if exists already
    var exists = await categoryExists(category.title);

    if(exists) return 0;

    var db = await OfflineDbProvider.provider.database;
    //get the biggest id in the table
    var table = await db.rawQuery("SELECT MAX(id) as id FROM Category");
    int id = table.first["id"] == null ? 1 : table.first["id"] + 1;
    //insert to the table using the new id
    var resultId = await db.rawInsert(
        "INSERT Into Category (id, title, desc, iconCodePoint)"
        " VALUES (?,?,?,?)",
        [id, category.title, category.desc, category.iconCodePoint.toString()]);
    return resultId;
  }

  Future<bool> categoryExists(String title) async {
    var db = await OfflineDbProvider.provider.database;
    var res = await db.query("Category");
    if (res.isEmpty) return false;

    var entity = res.firstWhere(
        (b) =>
            b["title"] == title,
        orElse: () => null);

    if (entity == null) return false;

    return entity.isNotEmpty;
  }

If same titled category doesn’t exist, then we create a new one. Similarly, here’s the implementation for getAllCategories:

@override
  Future<BuiltList<CategoryModel>> getAllCategories() async {
    var db = await OfflineDbProvider.provider.database;
    var res = await db.query("Category");
    if (res.isEmpty) return BuiltList();  

    var list = BuiltList<CategoryModel>();
    res.forEach((cat) {
      var category = serializers.deserializeWith<CategoryModel>(CategoryModel.serializer, cat);
     list = list.rebuild((b) => b..add(category));
    });

    return list.rebuild((b) => b..sort((a,b) => a.title.compareTo(b.title)));
  }

Here, we are deserializing the category list from SQLite database with the help of built value serializer.

Our basic service layer is ready for now.

Wrapping Up

In this post we learnt about serializable models and offline database in Flutter.

So far, we have created a basic category model, added SQLite database provider and a service layer to access category table. In the next part of this Flutter tutorial series, we will add more features to the Expense Manager app.

We will learn introduce the BLoC pattern, Inherited widgets, and Stream Builders. Stay tuned!

Part 1

Part 3