Using Sembast with Riverpod

In this tutorial I show you how to build a local stoarge with Sembast and Riverpod

In my tutorial Sembast as local data storage in Flutter, I used BLoC for state management. This time, I will create the same example application using Riverpod.

Sembast is a NoSQL database that can be used as a local storage in your Flutter application. It is fast and easy to use for storing JSON data locally. As in the previous tutorial using BLoC, we will build an app to add, edit, delete and view a list of yummy cakes.

The source code to this tutorial is available on GitHub: https://github.com/bettercoding-dev/sembast-riverpod.

Prerequisite

As always, we start with a clean new project. (See 5 Things I do when starting a new Flutter project for more information).

Next, let’s add our dependencies to pubspec.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
name: sembast_riverpod
description: Using Sembast with Riverpod
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.15.1 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  sembast: ^3.1.1+1
  flutter_riverpod: ^1.0.3
  freezed_annotation: ^1.1.0
  json_annotation: ^4.4.0
  path_provider: ^2.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^1.0.0
  json_serializable: ^6.1.3
  freezed: ^1.1.1
  build_runner: ^2.1.7

flutter:
  uses-material-design: true

Of course, we will use sembast and flutter_riverpod for our project. For our Cake model class, we will use freezed to help us generate some convenience functions for us. For this, we also need to add freezed_annotations, json_annotations, json_serializable and build_runner to the dependencies (and dev_dependencies). Finally, we also need to add path_provider – this package will be used for locating the correct directory to store our Sembast database file.

flutter_lints is now default for each new Flutter project. This package helps to find bad coding practices and helps to improve your code.

Next, we create a file called global_providers.dart and add the following code:

1
2
3
4
5
6
import 'package:riverpod/riverpod.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';

final databaseProvider =
    Provider<Database>((_) => throw Exception('Database not initialized'));

Here we defined a Provider for our Sembast Database. But for now, it is not implemented yet. This is because the initialization of the database will be async, but we want to avoid using a FutureProvider. We will use Riverpod’s overrides to bind the initialized database to this provider later.

We will do this in our main.dart file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast_io.dart';
import 'package:sembast_riverpod/global_providers.dart';
import 'package:path/path.dart';
import 'package:sembast_riverpod/pages/home_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final appPath = await getApplicationDocumentsDirectory();
  appPath.createSync(recursive: true);
  final dbPath = join(appPath.path, 'cakes.db');
  final database = await databaseFactoryIo.openDatabase(dbPath);

  runApp(
    ProviderScope(
      overrides: [
        databaseProvider.overrideWithValue(database),
      ],
      child: const CakeApp(),
    ),
  );
}

class CakeApp extends StatelessWidget {
  const CakeApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HomePage(),
    );
  }
}
  • Line 10: This line ensures that packages that use native code (such as path_provider) have access to native code API’s. For more info, see this post on stackoverflow.
  • Lines 12-15: Here, we initialize our Sembast database. First, we use path_provider to get our app’s documents directory (line 12) and create it if it does not yet exist (line 13). In line 14 we create a path for our database file (called cakes.db) and finally open the Sembast database (line 15).
  • Lines 17-24: Here we run our app and initialize Riverpod’s ProviderScope. This is also where we override the databaseProvider and assign it our initialized database (line 20).
  • Lines 27-36: This is all we need for defining our CakeApp. It will open our HomePage that we will create later.

The Cake Model

Next, we create our Cake model class that holds all information about the cakes we want to display. For this, we create a new folder models and add a cake.dart file. We’ll be using freezed to generate some functions for us.

The _$Cake, _Cake and _$CakeFromJson objects will not be found by your IDE or editor when you first define your model. They are generated by freezed later when calling the dart run build_runner build --delete-conflicting-outputs command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import 'package:freezed_annotation/freezed_annotation.dart';

part 'cake.g.dart';

part 'cake.freezed.dart';

@freezed
class Cake with _$Cake {
  const factory Cake({
    @Default(-1) int id,
    required String name,
    required int yummyness,
  }) = _Cake;

  factory Cake.fromJson(Map<String, dynamic> json) => _$CakeFromJson(json);
}
  • Lines 3-5: We use freezed and json_serializable to generate some handy functions for our Cake model (such as toString, copyWith, toJson, fromJson, …). The generated code is put to the files specified in line 3 and 5. We can link them to this file using the part keyword.

  • Line 7: Don’t forget the @freezed decorator!

  • Lines 8-16: Here we define our model class. We use two factory constructors. The first one (lines 9-13) is used to define the attributes of our model, the second one tells freezed to a fromJson constructor (line 15).

Check out the [freezed](https://pub.dev/packages/freezed) page on pub.dev or watch this video for more information.

Now that we specified everything call

dart run build_runner build --delete-conflicting-outputs

to generate code once or

dart run build_runner watch --delete-conflicting-outputs  

to watch for file changes, to continuously generate code.

The Cake Repository

In this section, we will finally use Sembast to read, write and update data to our local database. As with the Sembast tutorial using BLoC, we create an abstract CakeRepository first, to allow abstraction. In a new repositories directory, add a file called cake_repository.dart with this content:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import 'package:sembast_riverpod/models/cake.dart';

abstract class CakeRepository {
  Future<int> insertCake(Cake cake);

  Future updateCake(Cake cake);

  Future deleteCake(int cakeId);

  Stream<List<Cake>> getAllCakesStream();
}

In comparison to the other tutorial, we will now take advantage of the improved Sembast library, that now allows to receive changes to the database as a stream.

So, let’s implement the Repository in a new file called sembast_cake_repository.dart.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast_riverpod/global_providers.dart';
import 'package:sembast_riverpod/models/cake.dart';
import 'package:sembast_riverpod/repositories/cake_repository.dart';

final cakeRepositoryProvider = Provider(
  (ref) => SembastCakeRepository(
    database: ref.watch(databaseProvider),
  ),
);


class SembastCakeRepository implements CakeRepository {
  final Database database;
  late final StoreRef<int, Map<String, dynamic>> _store;

  SembastCakeRepository({required this.database}) {
    _store = intMapStoreFactory.store('cake_store');
  }

  @override
  Future<int> insertCake(Cake cake) => _store.add(database, cake.toJson());

  @override
  Future<void> updateCake(Cake cake) =>
      _store.record(cake.id).update(database, cake.toJson());

  @override
  Future deleteCake(int cakeId) => _store.record(cakeId).delete(database);

  @override
  Stream<List<Cake>> getAllCakesStream() =>
      _store.query().onSnapshots(database).map(
            (snapshot) => snapshot
                .map((cake) => Cake.fromJson(cake.value).copyWith(id: cake.key))
                .toList(growable: false),
          );
}
  • Lines 7-11: Here, we define our Riverpod provider to provide the SembastCakeRepository to other parts of our application and inject its dependencies.
  • Lines 14-20: We create a new class that implements the abstract CakeRepository. The SembastCakeRepository contains two properties: database and _store that inject and create in the constructor.
  • Lines 22-30: Inserting, updating and deleting with Sembast is really simple. Here we make use of the generated toJson functions.
  • Lines 32-38: With Sembast it is also possible to listen to changes in your database and receive the updated data as a Stream. We can do this, by creating a query on the _store (an empty query returns all objects). The onSnapshots functions is where Sembast returns us the stream of changes. We convert the snapshots into a list of Cakes.

The Cake Page

Finally, all that is left is the user interface. As described in my blog post Tutorial: Simple Riverpod App Architecture in Flutter, we will have three files:

  • home_page.dart: Here, we define how the UI looks like.
  • home_controller.dart: In this file, we will handle use input.
  • home_state.dart: This file will hold the state of the HomePage.

Add all these files into a new pages directory.

home_state.dart

1
2
3
4
5
6
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sembast_riverpod/repositories/sembast_cake_repository.dart';

final cakesProvider = StreamProvider(
  (ref) => ref.watch(cakeRepositoryProvider).getAllCakesStream(),
);

This simple file only holds one Provider: cakesProvider is a StreamProvider that uses the CakeRepository to get a stream of all available cakes.

home_controller.dart

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import 'dart:math';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sembast_riverpod/models/cake.dart';
import 'package:sembast_riverpod/repositories/cake_repository.dart';
import 'package:sembast_riverpod/repositories/sembast_cake_repository.dart';

final homeControllerProvider = Provider(
  (ref) => HomeController(
    cakeRepository: ref.watch(cakeRepositoryProvider),
  ),
);

class HomeController {
  static const flavors = ['apple', 'orange', 'chocolate'];
  final CakeRepository cakeRepository;

  HomeController({required this.cakeRepository});

  Future<void> delete(Cake cake) async {
    await cakeRepository.deleteCake(cake.id);
  }

  Future<void> edit(Cake cake) async {
    final updatedCake = cake.copyWith(yummyness: cake.yummyness + 1);
    await cakeRepository.updateCake(updatedCake);
  }

  Future<void> add() async {
    final flavorIndex = Random().nextInt(flavors.length - 1);
    final newCake = Cake(
      name: 'My yummy ${flavors[flavorIndex]} cake',
      yummyness: Random().nextInt(10),
    );
    await cakeRepository.insertCake(newCake);
  }
}
  • Lines 8-12: Here, we create a Provider that will make the HomeController available to the HomePage.
  • Lines 14-18: The HomeController class contains a list of flavors that will be used when creating a new Cake. It also holds a reference to the CakeRepository that will be used to handle the different user interactions.
  • Lines 20-36: We add functions to allow the handling of delete, edit, and add actions by the user. Delete will remove the cake from the database, edit will increase the yummyness factor by one, and add will randomly add a new flavored cake to the database. Since the stream will automatically update, there is no need to query the database manually.

home_page.dart

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sembast_riverpod/pages/home_controller.dart';
import 'package:sembast_riverpod/pages/home_state.dart';

class HomePage extends ConsumerWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = ref.watch(homeControllerProvider);
    final cakes = ref.watch(cakesProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('My Favorite Cakes'),
      ),
      body: cakes.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, trace) => Center(child: Text(error.toString())),
        data: (cakes) => ListView.builder(
          itemCount: cakes.length,
          itemBuilder: (context, index){
            final cake = cakes[index];
            return ListTile(
              title: Text(cake.name),
              subtitle: Text('Yummyness: ${cake.yummyness}'),
              leading: IconButton(
                icon: const Icon(Icons.thumb_up),
                onPressed: () => controller.edit(cake),
              ),
              trailing: IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () => controller.delete(cake),
              ),
            );
          },
        )
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () => controller.add(),
      ),
    );
  }
}
  • Line 6: Instead of StatelessWidget, we extend from ConsumerWidget to get access to WidgetRef in our build function.
  • Lines 11-12: Using the WidgetRef we can listen to the homeControllerProvider and cakesProvider.
  • Lines 18-21: The StreamProvider returns an AsyncValue. This class gives us access to a when function to handle different states of the stream. We can specify widgets for loading, error, and data (success) state.
  • Lines 21-38: We simply use a ListView to display the Cakes. Every ListTile has two buttons: one to increase yummyness and one to delete the Cake. We simply use the controller to handle the clicks.
  • Lines 40-43: Finally, to add new Cakes we create a FloatingActionButton that also uses the controller to delegate the action.

When we now run our application, this is what we should see. Our app should behave the same as in Tutorial: Sembast as local data storage in Flutter, but uses Riverpod for state management.

This is how the final app should look like.

The source code to this tutorial is available on GitHub: https://github.com/bettercoding-dev/sembast-riverpod.

Summary

No matter which state management solution we use, Sembast is a solid database package for storing your local data in a NoSQL way. In this tutorial, I transformed the tutorial using BLoC into a Riverpod-driven application.

Which state management solution do you prefer? Do you also use Sembast for storing your data, or do you use packages like Hive or sqflite?

Built with Hugo
Theme based on Stack designed by Jimmy