2

Flutter NoSQL Database – SEMBAST Tutorial (w/ BLoC)

Persistently storing data in Flutter is not one of the easiest experiences if you're just starting out. If you want to move beyond simple "Preferences", which are only key - value pairs, you are probably looking at a library like SQFLite. The problem with this library is that it's very low level and maybe you really don't need to use structured data. What about NoSQL in Flutter?

In this tutorial you're going to learn about SEMBAST (Simple Embedded Application Store) which is a very powerful, yet simple to use library for storing, querying, ordering, paginating and even encrypting data.

Importing packages

Before we can start working with the SEMBAST library, we need to add it and also some other libraries to our pubspec.yaml file. Because we don't want to end up with spaghetti code, we are going to use BLoC for state management. You can, however, obviously use any kind of state management pattern in your projects.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  sembast: ^1.15.1
  path_provider: ^0.5.0+1
  # Used for BLoC state management
  flutter_bloc: ^0.9.1
  equatable: ^0.2.3

Building the data layer

Like most of the database libraries out there, SEMBAST needs to be opened before we can use it to store or retrieve data. This is so that the library can establish a connection with afile on the persistent storage in which all of the data is stored.

Opening a database 🚪🔓

We are going to create an AppDatabase class to hold all of the database opening logic. One more thing about a database being opened - it needs to happen only once. For that reason,  AppDatabase class will be a singleton - a class with only a single instance. Singleton is a design pattern which makes sure we can very simply access an instance of a class, while ensuring that there can be only one instance of a given type.

app_database.dart

import 'dart:async';

import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';

class AppDatabase {
  // Singleton instance
  static final AppDatabase _singleton = AppDatabase._();

  // Singleton accessor
  static AppDatabase get instance => _singleton;

  // Completer is used for transforming synchronous code into asynchronous code.
  Completer<Database> _dbOpenCompleter;

  // A private constructor. Allows us to create instances of AppDatabase
  // only from within the AppDatabase class itself.
  AppDatabase._();

  // Sembast database object
  Database _database;

  // Database object accessor
  Future<Database> get database async {
    // If completer is null, AppDatabaseClass is newly instantiated, so database is not yet opened
    if (_dbOpenCompleter == null) {
      _dbOpenCompleter = Completer();
      // Calling _openDatabase will also complete the completer with database instance
      _openDatabase();
    }
    // If the database is already opened, awaiting the future will happen instantly.
    // Otherwise, awaiting the returned future will take some time - until complete() is called
    // on the Completer in _openDatabase() below.
    return _dbOpenCompleter.future;
  }

  Future _openDatabase() async {
    // Get a platform-specific directory where persistent app data can be stored
    final appDocumentDir = await getApplicationDocumentsDirectory();
    // Path with the form: /platform-specific-directory/demo.db
    final dbPath = join(appDocumentDir.path, 'demo.db');

    final database = await databaseFactoryIo.openDatabase(dbPath);
    // Any code awaiting the Completer's future will now start executing
    _dbOpenCompleter.complete(database);
  }
}

We want to be able to return the newly opened Database instance. However, once the database is already opened (if we call the database getter for the second time),  we don't want to open the database for the second time! What we want to do is to return the already opened instance.

The catch is that opening a database is an asynchronous operation - it takes some time. Therefore, the database getter must  return a Future.  How can we then await the database to be opened the first time the getter is called and then return the already opened instance on subsequent calls?

We can use a Completer. It's a class for making and storing our own Futures and we want precisely that.

Creating a model class 🍎🍌🥝

Once we have the low-level database class finished, we want to move on to writing a Data Access Object which can be used to actually facilitate the CRUD (create, read, update, delete) operations. The problem is, we currently don't have anything to store in the database.

In this tutorial, we are going to build an app displaying a list of fruits. Let's create a Fruit class! SEMBAST stores data as JSON strings, so we need to have a way to convert Fruit objects to Map<String, dynamic> - converting from Map to JSON is handled directly by SEMBAST.

fruit.dart

import 'package:meta/meta.dart';

class Fruit {
  // Id will be gotten from the database.
  // It's automatically generated & unique for every stored Fruit.
  int id;

  final String name;
  final bool isSweet;

  Fruit({
    @required this.name,
    @required this.isSweet,
  });

  Map<String, dynamic> toMap() {
    return {
      'name': name,
      'isSweet': isSweet,
    };
  }

  static Fruit fromMap(Map<String, dynamic> map) {
    return Fruit(
      name: map['name'],
      isSweet: map['isSweet'],
    );
  }
}

Data Access Object for Fruit

Now that we have the core low-level AppDatabase class which is a singleton used to get the single opened instance of a SEMBAST database and we also have a Fruit model class, we can finally write functions responsible for inserting, updating, deleting and getting data from the database. By convention, such classes are called Data Access Objects or shortly Dao.

fruit_dao.dart

import 'package:sembast/sembast.dart';
import 'package:sembast_prep/data/app_database.dart';
import 'package:sembast_prep/data/fruit.dart';

class FruitDao {
  static const String FRUIT_STORE_NAME = 'fruits';
  // A Store with int keys and Map<String, dynamic> values.
  // This Store acts like a persistent map, values of which are Fruit objects converted to Map
  final _fruitStore = intMapStoreFactory.store(FRUIT_STORE_NAME);

  // Private getter to shorten the amount of code needed to get the
  // singleton instance of an opened database.
  Future<Database> get _db async => await AppDatabase.instance.database;

  Future insert(Fruit fruit) async {
    await _fruitStore.add(await _db, fruit.toMap());
  }

  Future update(Fruit fruit) async {
    // For filtering by key (ID), RegEx, greater than, and many other criteria,
    // we use a Finder.
    final finder = Finder(filter: Filter.byKey(fruit.id));
    await _fruitStore.update(
      await _db,
      fruit.toMap(),
      finder: finder,
    );
  }

  Future delete(Fruit fruit) async {
    final finder = Finder(filter: Filter.byKey(fruit.id));
    await _fruitStore.delete(
      await _db,
      finder: finder,
    );
  }

  Future<List<Fruit>> getAllSortedByName() async {
    // Finder object can also sort data.
    final finder = Finder(sortOrders: [
      SortOrder('name'),
    ]);

    final recordSnapshots = await _fruitStore.find(
      await _db,
      finder: finder,
    );

    // Making a List<Fruit> out of List<RecordSnapshot>
    return recordSnapshots.map((snapshot) {
      final fruit = Fruit.fromMap(snapshot.value);
      // An ID is a key of a record from the database.
      fruit.id = snapshot.key;
      return fruit;
    }).toList();
  }
}

Managing the app state with BLoC

Currently, we have the model layer finished - we can read and write to the SEMBAST NoSQL database. While we could call the FruitDao's functions directly from Widgets, doing so would tightly couple our UI with the SEMBAST DB.

While in this tutorial we are surely not going to move away from SEMBAST, in a real project it's more than expected that sooner or later you will perform some drastic changes to how your app is wired. You might want to store data on a remote server, for instance.

This is the reason for adding a middleman between the UI and the model layer. We are going to use arguably the best state management library out there and that is BLoC.

BLoC stands for Business Logic Component and it is a reactive state management pattern for Flutter. Feel free to use your preferred state management technique. If you want to learn about BLoC in depth, here is a separate tutorial.

In short, BLoC is like a box which takes in events and outputs state. Events and states are simply normal objects. As soon as the event gets inside the proverbial "box", a function runs where we can determine which state to output.

For example, if the BLoC receives an event called LoadFruits, it will first output FruitsLoading state so that the UI can display a loading indicator, and then, once the fruits are asynchronously gotten from the database, the BLoC will emit another state called FruitsLoaded which will hold the List of fruits.

Thus we want to create 3 files - fruit_event.dart, fruit_state.dart, and fruit_bloc.dart. Let's start with the event - the thing which goes "into the box".

fruit_event.dart

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:sembast_prep/data/fruit.dart';

@immutable
abstract class FruitEvent extends Equatable {
  FruitEvent([List props = const []]) : super(props);
}

class LoadFruits extends FruitEvent {}

class AddRandomFruit extends FruitEvent {}

class UpdateWithRandomFruit extends FruitEvent {
  final Fruit updatedFruit;

  UpdateWithRandomFruit(this.updatedFruit) : super([updatedFruit]);
}

class DeleteFruit extends FruitEvent {
  final Fruit fruit;

  DeleteFruit(this.fruit) : super([fruit]);
}
You might be wondering what's up with Equatable which is the superclass of FruitEvent. Objects in Dart have referential equality by default, Equatable ensures value equality. This means, that now different objects with the same values will be regarded as equal.

We have 4 events which will initiate the logic inside the BLoC and subsequently return a state for the UI to display data on the screen with it. State classes will be even simpler than events. There will be only 2 of them - FruitsLoading and FruitsLoaded.

fruit_state.dart

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:sembast_prep/data/fruit.dart';

@immutable
abstract class FruitState extends Equatable {
  FruitState([List props = const []]) : super(props);
}

class FruitsLoading extends FruitState {}

class FruitsLoaded extends FruitState {
  final List<Fruit> fruits;

  FruitsLoaded(this.fruits) : super([fruits]);
}

Finally, we need to actually connect events with the states together by writing some logic. Since we are using Business Logic Component, there is no better place to write this connecting logic than in the BLoC itself.

fruit_bloc.dart

import 'dart:async';
import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:sembast_prep/data/fruit.dart';
import 'package:sembast_prep/data/fruit_dao.dart';
import './bloc.dart';

class FruitBloc extends Bloc<FruitEvent, FruitState> {
  FruitDao _fruitDao = FruitDao();

  // Display a loading indicator right from the start of the app
  @override
  FruitState get initialState => FruitsLoading();


  // This is where we place the logic.
  @override
  Stream<FruitState> mapEventToState(
    FruitEvent event,
  ) async* {
    if (event is LoadFruits) {
      // Indicating that fruits are being loaded - display progress indicator.
      yield FruitsLoading();
      yield* _reloadFruits();
    } else if (event is AddRandomFruit) {
      // Loading indicator shouldn't be displayed while adding/updating/deleting
      // a single Fruit from the database - we aren't yielding FruitsLoading().
      await _fruitDao.insert(RandomFruitGenerator.getRandomFruit());
      yield* _reloadFruits();
    } else if (event is UpdateWithRandomFruit) {
      final newFruit = RandomFruitGenerator.getRandomFruit();
      // Keeping the ID of the Fruit the same
      newFruit.id = event.updatedFruit.id;
      await _fruitDao.update(newFruit);
      yield* _reloadFruits();
    } else if (event is DeleteFruit) {
      await _fruitDao.delete(event.fruit);
      yield* _reloadFruits();
    }
  }

  Stream<FruitState> _reloadFruits() async* {
    final fruits = await _fruitDao.getAllSortedByName();
    // Yielding a state bundled with the Fruits from the database.
    yield FruitsLoaded(fruits);
  }
}

class RandomFruitGenerator {
  static final _fruits = [
    Fruit(name: 'Banana', isSweet: true),
    Fruit(name: 'Strawberry', isSweet: true),
    Fruit(name: 'Kiwi', isSweet: false),
    Fruit(name: 'Apple', isSweet: true),
    Fruit(name: 'Pear', isSweet: true),
    Fruit(name: 'Lemon', isSweet: false),
  ];

  static Fruit getRandomFruit() {
    return _fruits[Random().nextInt(_fruits.length)];
  }
}
For the sake of simplicity, we generate new fruits randomly. Otherwise you'd bundle the new fruit together with the AddRandomFruit event, similar to how UpdateWithRandomFruit event comes with a updatedFruit field.

Building the user interface

There can be no Flutter app if it doesn't have a UI. With all of the prior classes in place, we can finally put everything into practice.

The UI of the fruit app will be very simple - a ListView containing ListTiles. Those will contain the name and sweetness of the fruit and two buttons. One for updating the fruit in the database with a new random one, and another button for deleting the fruit.

main.dart

import 'package:flutter/material.dart';
import 'package:sembast_prep/fruit_bloc/fruit_bloc.dart';
import 'package:sembast_prep/home_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Wrapping the whole app with BlocProvider to get access to FruitBloc everywhere
    // BlocProvider extends InheritedWidget.
    return BlocProvider(
      bloc: FruitBloc(),
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.yellow,
          accentColor: Colors.redAccent,
        ),
        home: HomePage(),
      ),
    );
  }
}

Bloc library provides a BlocProvider widget which is a subclass of InheritedWidget. This means that we can access the FruitBloc from other widgets down the tree, including the HomePage, where the ListView will be located.

home_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sembast_prep/data/fruit.dart';
import 'package:sembast_prep/fruit_bloc/bloc.dart';

class HomePage extends StatefulWidget {
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  FruitBloc _fruitBloc;

  @override
  void initState() {
    super.initState();
    // Obtaining the FruitBloc instance through BlocProvider which is an InheritedWidget
    _fruitBloc = BlocProvider.of<FruitBloc>(context);
    // Events can be passed into the bloc by calling dispatch.
    // We want to start loading fruits right from the start.
    _fruitBloc.dispatch(LoadFruits());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Fruit app'),
      ),
      body: _buildBody(),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          _fruitBloc.dispatch(AddRandomFruit());
        },
      ),
    );
  }

  Widget _buildBody() {
    return BlocBuilder(
      bloc: _fruitBloc,
      // Whenever there is a new state emitted from the bloc, builder runs.
      builder: (BuildContext context, FruitState state) {
        if (state is FruitsLoading) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else if (state is FruitsLoaded) {
          return ListView.builder(
            itemCount: state.fruits.length,
            itemBuilder: (context, index) {
              final displayedFruit = state.fruits[index];
              return ListTile(
                title: Text(displayedFruit.name),
                subtitle:
                    Text(displayedFruit.isSweet ? 'Very sweet!' : 'Sooo sour!'),
                trailing: _buildUpdateDeleteButtons(displayedFruit),
              );
            },
          );
        }
      },
    );
  }

  Row _buildUpdateDeleteButtons(Fruit displayedFruit) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        IconButton(
          icon: Icon(Icons.refresh),
          onPressed: () {
            _fruitBloc.dispatch(UpdateWithRandomFruit(displayedFruit));
          },
        ),
        IconButton(
          icon: Icon(Icons.delete_outline),
          onPressed: () {
            _fruitBloc.dispatch(DeleteFruit(displayedFruit));
          },
        ),
      ],
    );
  }
}

Conclusion

SEMBAST DB is a very powerful NoSQL persistent storage option for Flutter. It's ease of setup and use makes it a viable option for your apps, especially if you don't want to bother with an SQL approach.

You've learned how to make a simple yet complete app with SEMBAST using BLoC for state management. Be sure to check out the video tutorial for a more hands-on perspective of building this app.

Database icon made by Smashicons from www.flaticon.com is licensed by CC 3.0 BY
Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • Peter shawky says:

    can we connect this no sql db to the firebase firestore ?

  • >