Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

15. Saving Data Locally
Written by Kevin D Moore

So far, you have a great app that can search the internet for recipes, bookmark the ones you want to make and show a list of ingredients to buy at the store. But what happens if you close the app, go to the store and try to look up your ingredients? They’re gone! As you might have guessed, having an in-memory repository means that the data doesn’t persist after your app closes.

One of the best ways to persist data is with a database. Android, iOS, macOS, Windows and the web provide the SQLite database system access. This allows you to insert, read, update and remove structured data that are persisted on disk.

In this chapter, you’ll learn about using the Drift and sqlbrite packages.

By the end of the chapter, you’ll know:

  • How to insert, fetch and remove recipes or ingredients.
  • How to use the sqlbrite library and receive updates via streams.
  • How to leverage the features of the Drift library when working with databases.

Databases

Databases have been around for a long time, but being able to put a full-blown database on a phone is pretty amazing.

What is a database? Think of it like a file cabinet containing folders with sheets of paper. A database has tables, file folders that store data, and sheets of paper.

Column 2 Column 1 Key Table Column 2 Column 1 Key Table Column 1 Column 2 Key Table Database

Database tables have columns defining data, which are then stored in rows. One of the most popular database management languages is Structured Query Language, commonly known as SQL.

You use SQL commands to get the data in and out of the database.

Using SQL

The SQLite database system on Android and iOS is an embedded engine that runs in the same process as the app. SQLite is lightweight, taking up less than 500 KB on most systems.

When SQLite creates a database, it stores it in one file inside the app. These files are cross-platform, meaning you can pull a file off a phone and read it on a regular computer.

Unlike a database server, SQLite needs no server configuration or server process.

While SQLite is small and runs fast, it still requires some knowledge of the SQL language and how to create databases, tables and execute SQL commands.

Writing Queries

One of the most important parts of SQL is writing a query. A query is a question or inquiry about a data set. To make a query, use the SELECT command followed by any columns you want the database to return, then the table name. For example:

// 1
SELECT name, address FROM Customers;
// 2
SELECT * FROM Customers;
// 3
SELECT name, address FROM Customers WHERE name LIKE 'A%';

Here’s what’s happening in the code above:

  1. Returns the name and address columns from the CUSTOMERS table.
  2. Using *, returns all columns from the specified table.
  3. Uses WHERE to filter the returned data. In this case, it only returns data where NAME starts with A.

Adding Data

You can add data using the INSERT statement:

INSERT INTO Customers (NAME, ADDRESS) VALUES (value1, value2);

While you don’t have to list all the columns, if you want to add all the values, the values must be in the order you used to define the columns. It’s best practice to list the column names whenever you insert data. That makes it easier to update your values list if, say, you add a column in the middle.

Deleting Data

To delete data, use the DELETE statement:

DELETE FROM Customers WHERE id = 1;

If you don’t use the WHERE clause, you’ll delete all the data from the table. Here, you delete the customer whose id equals 1. You can use broader conditions, of course. For example, you might delete all the customers with a given city.

Updating Data

You use UPDATE to update your data. You won’t need this command for this app, but for reference, the syntax is:

UPDATE customers
SET
  phone = '555-12345',
WHERE id = 1;

This updates the customer’s phone number whose id equals 1.

sqlbrite

The sqlbrite library is a reactive stream wrapper around sqflite. It allows you to set up streams so you can receive events when there’s a change in your database. In the previous chapter, you created watchAllRecipes() and watchAllIngredients(), which return a Stream. To create these streams from a database, sqlbrite uses a similar approach.

Adding a Database to Recipe Finder

If you’re following along with your app, open it and keep using it with this chapter. If not, locate this chapter’s projects folder and open the starter folder.

Note: If you use the starter app, don’t forget to add your apiKey in network/spoonacular_service.dart.

Your app manages two types of data: recipes and ingredients, which you’ll model according to this diagram:

Column 2 Column 1 Key Table Column 2 Column 1 Key Table Column 1 Column 2 Key Table Recipes Ingredients Database Repository

In this chapter, you’ll use the Drift package. You’ll then swap the memory repository for the new database repository.

Adding Libraries

Open pubspec.yaml and add the following packages after the flutter_riverpod package:

synchronized: ^3.1.0
sqlbrite: ^2.6.0
sqlite3_flutter_libs: ^0.5.18
web_ffi: ^0.7.2
sqlite3: ^2.1.0

These packages provide the following:

  1. synchronized: Helps implement lock mechanisms to prevent concurrent access.
  2. sqlbrite: Reactive wrapper around sqflite that receives changes happening in the database via streams.
  3. sqlite3_flutter_libs: Native sqlite3 libraries for mobile.
  4. web_ffi: web_ffi is a drop-in solution for using dart:ffi on the web. Used for Flutter web databases.
  5. sqlite3: Provides Dart bindings to SQLite via dart:ffi

Run Pub Get or flutter pub get.

Now, you’re ready to create your first database.

Using the Drift Library

Drift is a package that’s intentionally similar to Android’s Room library.

You don’t need to write SQL code and the setup is a lot easier. You’ll write specific Dart classes, and Drift will take care of the necessary translations to and from SQL code.

You need one file for dealing with the database and one for the repository. To start, add Drift to pubspec.yaml, after sqlite3:

drift: ^2.13.1

Next, add the Drift generator, which will write code for you, in the dev_dependencies section after chopper_generator:

drift_dev: ^2.13.2

Finally, run any of the following:

  • flutter pub get from Terminal
  • Pub get from the IDE window
  • Tools ▸ Flutter ▸ Flutter Pub Get

Database Classes

For your next step, you need to create a set of classes that will describe and create the database, tables and Data Access Objects (DAOs). Below is a diagram showing how your database will look.

Table DatabaseAccessor DatabaseAccessor DbRecipe DbIngredient RecipeDao IngredientDao Table Database RecipeDatabase

Database, Table and DatabaseAccessor are from Drift. You’ll create the other classes.

Note: A DAO (Data Access Object) is a class that’s in charge of accessing data from the database. You use it to separate your business logic code, e.g., the one that fetches the ingredients of a recipe, from the details of the persistence layer, which is SQLite in this case. A DAO can be a class, an interface or an abstract class. In this chapter, you’ll implement DAOs using classes.

Open the following files in the data/database directory and uncomment the code:

  1. unsupported.dart
  2. native.dart
  3. web.dart

These files will be used below.

Inside database, create a file called recipe_db.dart. This file will define the database for recipes and ingredients. Add the following imports:

import 'package:drift/drift.dart';
import 'connection.dart' as impl;
import '../models/models.dart';

This will add Drift and your models. connection.dart allows the code to create a connection based on whether the app is running on mobile, desktop or the web.

Now, add a part statement and some TODOs:

part 'recipe_db.g.dart';

// TODO: Add DbRecipe table definition here

// TODO: Add DbIngredient table definition here

// TODO: Add @DriftDatabase() and RecipeDatabase() here

// TODO: Add RecipeDao here

// TODO: Add IngredientDao

// TODO: Add dbRecipeToModelRecipe here

// TODO: Add recipeToInsertableDbRecipe here

// TODO: Add dbIngredientToIngredient and ingredientToInsertableDbIngredient here

Remember that the part statement is a way to combine one file into another to form a whole file. The Drift generator will create this file for you later when you run the build_runner command. Until then, it’ll display a red squiggle.

Creating Recipe and Ingredient Tables

To create a table in Drift, you need to create a class that extends Table. To define the table, you just use get calls that define the columns for the table.

Still in recipe_db.dart, replace // TODO: Add DbRecipe table definition here with the following:

// 1
class DbRecipe extends Table {
  // 2
  IntColumn get id => integer().autoIncrement()();

  // 3
  TextColumn get label => text()();

  // 4
  TextColumn get image => text()();

  // 5
  TextColumn get description => text()();

  // 6
  BoolColumn get bookmarked  => boolean()();

}

Here’s what you do in this code:

  1. Create a class named DbRecipe that extends Table.
  2. Create a column named id with type as an integer. autoIncrement() automatically creates and increments the IDs for you.
  3. Create a label column made up of text.
  4. Create an image column for storing the URL of the image.
  5. Create a description text column.
  6. Create a bookmarked column of type Boolean.

This definition is a bit unusual. You first define the column type with type classes that handle different types:

  • IntColumn: Integers.
  • BoolColumn: Booleans.
  • TextColumn: Text.
  • DateTimeColumn: Dates.
  • RealColumn: Doubles.
  • BlobColumn: Arbitrary blobs of data.

It also uses a “double” method call, where each call returns a builder. For example, to create IntColumn, you need to make a final call with the extra () to create it.

Defining the Ingredient Table

Now, find and replace // TODO: Add DbIngredient table definition here with the following:

class DbIngredient extends Table {
  IntColumn get id => integer().autoIncrement()();

  IntColumn get recipeId => integer()();

  TextColumn get name => text()();

  RealColumn get amount => real()();

}

Now, for the fun part.

Creating the Database Class

Drift uses annotations. The first one you need is @DriftDatabase. This specifies the tables and Data Access Objects (DAO) to use.

Still in recipe_db.dart, add this class with the annotation by replacing // TODO: Add @DriftDatabase and RecipeDatabase() here with the following:

// 1
@DriftDatabase(
  tables: [
    DbRecipe,
    DbIngredient,
  ],
  daos: [
    RecipeDao,
    IngredientDao,
  ]
)
// 2
class RecipeDatabase extends _$RecipeDatabase {
  // 3
  RecipeDatabase() : super(impl.connect());

  // 4
  @override
  int get schemaVersion => 1;
}

Here’s what the above code does:

  1. Describe the tables, which you defined above, and DAOs this database will use. You’ll create the DAOs in a bit.
  2. Extend _$RecipeDatabase, which the Drift generator will create. This doesn’t exist yet, but the part import at the top will include it.
  3. To support the web platform, one of the imports is connection.dart. This file will import either the native or web files so you get the proper database initialization.
  4. Set the database or schema version to 1. Increment this when your database changes.

There’s still a bit more to do. You need to create DAOs, which are classes that are specific to a table and allow you to call methods to access that table.

Creating the DAO Classes

Your first step is to create the RecipeDao class. You’ll see more red squiggles, just ignore them for now. With recipe_db.dart still open, replace // TODO: Add RecipeDao here with the following:

// 1
@DriftAccessor(tables: [DbRecipe])
// 2
class RecipeDao extends DatabaseAccessor<RecipeDatabase> with _$RecipeDaoMixin {
  // 3
  final RecipeDatabase db;

  RecipeDao(this.db) : super(db);

  // 4
  Future<List<DbRecipeData>> findAllRecipes() => select(dbRecipe).get();

  // 5
  Stream<List<Recipe>> watchAllRecipes() {
     // TODO: Add watchAllRecipes code here
  }

  // 6
  Future<List<DbRecipeData>> findRecipeById(int id) =>
      (select(dbRecipe)..where((tbl) => tbl.id.equals(id))).get();

  // 7
  Future<int> insertRecipe(Insertable<DbRecipeData> recipe) =>
      into(dbRecipe).insert(recipe);

  // 8
  Future deleteRecipe(int id) => Future.value(
      (delete(dbRecipe)..where((tbl) => tbl.id.equals(id))).go());
}

Here’s what’s going on there:

  1. @DriftAccessor annotation that specifies the following class is a DAO class for the DbRecipe table.
  2. Create the DAO class that extends the Drift DatabaseAccessor with the mixin, _$RecipeDaoMixin. This mixin will be created for you.
  3. Create a field to hold an instance of your database.
  4. Use a simple select() query to find all recipes.
  5. Define watchAllRecipes(), but skip the implementation for now.
  6. Define a more complex query that uses where to fetch recipes by ID.
  7. Use into() and insert() to add a new recipe.
  8. Use delete() and where() to delete a specific recipe.

Drift can be a bit more complex to set up in some ways, but it’s easy to use. Most of these calls are one-liners and quite easy to read.

Let’s break down the find method:

(select(dbRecipe)..where((tbl) => tbl.id.equals(id))).get();
  1. select takes a table name.
  2. Use the .. to cascade a where function.
  3. This takes a variable name tbl. This can be any name.
  4. It returns all rows whose IDs match the one in the table.
  5. Call the get method to execute the query.

Inserting data is pretty simple. Just specify the table and pass in the class. Notice that you’re not passing the model recipe, you’re passing Insertable, which is an interface that Drift requires. When you generate the part file, you’ll see a new class, DbRecipeData, which implements this interface. Let’s break this down:

into(dbRecipe).insert(recipe)
  1. This method will insert a record into the recipe table.
  2. Execute the insert command with the given recipe.

Deleting requires the table and a where. This function just returns true for those rows you want to delete. Instead of get(), you use go().

Now, replace // TODO: Add IngredientDao with the following. Again, ignoring the red squiggles. They’ll go away when all the new classes are in place.

// 1
@DriftAccessor(tables: [DbIngredient])
// 2
class IngredientDao extends DatabaseAccessor<RecipeDatabase>
    with _$IngredientDaoMixin {
  final RecipeDatabase db;

  IngredientDao(this.db) : super(db);

  Future<List<DbIngredientData>> findAllIngredients() =>
      select(dbIngredient).get();

  // 3
  Stream<List<DbIngredientData>> watchAllIngredients() =>
      select(dbIngredient).watch();

  // 4
  Future<List<DbIngredientData>> findRecipeIngredients(int id) =>
      (select(dbIngredient)..where((tbl) => tbl.recipeId.equals(id))).get();

  // 5
  Future<int> insertIngredient(Insertable<DbIngredientData> ingredient) =>
      into(dbIngredient).insert(ingredient);

  // 6
  Future deleteIngredient(int id) =>
      Future.value((delete(dbIngredient)..where((tbl) =>
          tbl.id.equals(id))).go());
}

Here’s what’s going on above:

  1. Similar to RecipeDao, you specify that this class is a DAO for DbIngredient.

  2. Extend DatabaseAccessor with _$IngredientDaoMixin.

  3. Call watch() to create a stream.

  4. Use where() to select all ingredients that match the recipe ID.

  5. Use into() and insert() to add a new ingredient.

  6. Use delete() plus where() to delete a specific ingredient.

Now it’s time to generate the part file.

Generating the Part File

Now, you need to create the Drift part file. In Terminal, run:

dart run build_runner build --delete-conflicting-outputs

This generates recipe_db.g.dart.

Note: --delete-conflicting-outputs deletes previously generated files and then rebuilds them.

After the file has been generated, open recipe_db.g.dart and take a look. It’s a very large file. It generated several classes, saving you a lot of work!

Note: If Android Studio doesn’t detect the presence of the newly generated recipe_db.g.dart file, right-click the lib folder and select Reload from Disk.

Now that you’ve defined these tables, you need to create methods that convert your database classes to your regular model classes and back.

Converting Your Drift Recipes

At the end of recipe_db.dart, replace // TODO: Add dbRecipeToModelRecipe here with:

// Conversion Methods
Recipe dbRecipeToModelRecipe(
    DbRecipeData recipe, List<Ingredient> ingredients) {
  return Recipe(
    id: recipe.id,
    label: recipe.label,
    image: recipe.image,
    description: recipe.description,
    bookmarked: recipe.bookmarked,
    ingredients: ingredients,
  );
}

This converts a Drift recipe to a model recipe.

The next method converts Recipe to a class that you can insert into a Drift database. Replace // TODO: Add recipeToInsertableDbRecipe here with this:

Insertable<DbRecipeData> recipeToInsertableDbRecipe(Recipe recipe) {
  return DbRecipeCompanion.insert(
    id: Value.ofNullable(recipe.id),
    label: recipe.label ?? '',
    image: recipe.image ?? '',
    description: recipe.description ?? '',
    bookmarked: recipe.bookmarked,
  );
}

Insertable is an interface for objects that can be inserted into the database or updated. Use the generated DbRecipeCompanion.insert() to create that class.

Creating Classes for Ingredients

Next, you’ll do the same for the ingredients models. Replace // TODO: Add dbIngredientToIngredient and ingredientToInsertableDbIngredient here with the following:

Ingredient dbIngredientToIngredient(DbIngredientData ingredient) {
  return Ingredient(
    id: ingredient.id,
    recipeId: ingredient.recipeId,
    name: ingredient.name,
    amount: ingredient.amount,
  );
}

DbIngredientCompanion ingredientToInsertableDbIngredient(
    Ingredient ingredient) {
  return DbIngredientCompanion.insert(
    recipeId: ingredient.recipeId ?? 0,
    name: ingredient.name ?? '',
    amount: ingredient.amount ?? 0,
  );
}

These methods convert a Drift ingredient into an instance of Ingredient and vice versa.

Updating watchAllRecipes()

Now that you’ve written the conversion methods, you can update watchAllRecipes().

You’ll notice most of the red squiggles in data/database/recipe_db.dart are now gone. But there’s one left.

Note: If you’re having problems, run flutter clean and flutter pub get in case your IDE isn’t up to date with the newly generated files.

If you’re still having problems, try deleting pubspec.lock, then run flutter clean, and flutter pub get.

Locate // TODO: Add watchAllRecipes code here and replace it with:

// 1
return select(dbRecipe)
  // 2
  .watch()
  // 3
  .map((rows) {
    final recipes = <Recipe>[];
    // 4
    for (final row in rows) {
      // 5
      final recipe = dbRecipeToModelRecipe(row, <Ingredient>[]);
      // 6
      if (!recipes.contains(recipe)) {
          recipes.add(recipe);
      }
    }
    return recipes;
  },
);

Here’s the step-by-step:

  1. Use select() to start a query.
  2. Create a stream.
  3. Map each list of rows.
  4. For each row, execute the code below.
  5. Convert the recipe row to a regular recipe with an empty ingredient list.
  6. Add the recipe to your recipes list.

This creates a stream of recipes.

No more red squiggles. :]

Run the app to make sure everything works correctly. You can run it on Android, iOS, macOs, the web or Windows. On the web, it should look something like:

Creating the Drift Repository

Now that you have the Drift database code written, you need to write a repository to handle it. You’ll create a class named DBRepository that implements Repository:

Repository MemoryRepository DbRepository

In the repositories directory, create a new file named db_repository.dart. Add the following imports:

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../database/recipe_db.dart';
import '../models/current_recipe_data.dart';
import '../models/models.dart';
import '../repositories/repository.dart';

This imports your models, the repository interface and your newly-created recipe_db.dart.

Next, create DBRepository and some fields:

class DBRepository extends Notifier<CurrentRecipeData> implements Repository {
  // 1
  late RecipeDatabase recipeDatabase;
  // 2
  late RecipeDao _recipeDao;
  // 3
  late IngredientDao _ingredientDao;
  // 4
  Stream<List<Ingredient>>? ingredientStream;
  // 5
  Stream<List<Recipe>>? recipeStream;

  @override
  CurrentRecipeData build() {
    const currentRecipeData = CurrentRecipeData();
    return currentRecipeData;
  }

  // TODO: Add findAllRecipes()
  // TODO: Add watchAllRecipes()
  // TODO: Add watchAllIngredients()
  // TODO: Add findRecipeById()
  // TODO: Add findAllIngredients()
  // TODO: Add findRecipeIngredients()
  // TODO: Add insertRecipe()
  // TODO: Add insertIngredients()
  // TODO: Add Delete methods

  @override
  Future init() async {
    // 6
    recipeDatabase = RecipeDatabase();
    // 7
    _recipeDao = recipeDatabase.recipeDao;
    _ingredientDao = recipeDatabase.ingredientDao;
  }

  @override
  void close() {
    // 8
    recipeDatabase.close();
  }
}

Here’s what’s happening in the code above:

  1. Stores an instance of the Drift RecipeDatabase.
  2. Stores a private RecipeDao to handle recipes.
  3. Stores a private IngredientDao that handles ingredients.
  4. Stores a stream that watches ingredients.
  5. Stores a stream that watches recipes.
  6. Creates your database.
  7. Gets instances of your DAOs.
  8. Closes the database.

Implementing the Repository

As you did in past chapters, you’ll now add all the missing methods following the TODO: indications. Replace // TODO: Add findAllRecipes() with:

@override
Future<List<Recipe>> findAllRecipes() {
  // 1
  return _recipeDao.findAllRecipes()
    // 2
    .then<List<Recipe>>(
    (List<DbRecipeData> dbRecipes) async {
      final recipes = <Recipe>[];
      // 3
      for (final dbRecipe in dbRecipes) {
        // 4
        final ingredients = await findRecipeIngredients(dbRecipe.id);
        // 5
        final recipe = dbRecipeToModelRecipe(dbRecipe, ingredients);
        recipes.add(recipe);
      }
      return recipes;
    },
  );
}

The code above does the following:

  1. Uses RecipeDao to find all recipes.
  2. Takes the list of DbRecipeData items, executing then after findAllRecipes() finishes.
  3. For each recipe:
  4. Gets a list of ingredients for the given recipe ID.
  5. Converts the Drift recipe to a model recipe, then adds the recipe to the list.

The next step is simple. Find // TODO: Add watchAllRecipes() and substitute it with:

@override
Stream<List<Recipe>> watchAllRecipes() {
  recipeStream ??= _recipeDao.watchAllRecipes();
  return recipeStream!;
}

This just calls the same method name on the recipe DAO class, then saves an instance so you don’t create multiple streams.

Next, replace // TODO: Add watchAllIngredients() with:

@override
Stream<List<Ingredient>> watchAllIngredients() {
  if (ingredientStream == null) {
    // 1
    final stream = _ingredientDao.watchAllIngredients();
    // 2
    ingredientStream = stream.map((dbIngredients) {
      final ingredients = <Ingredient>[];
      // 3
      for (final dbIngredient in dbIngredients) {
        ingredients.add(dbIngredientToIngredient(dbIngredient));
      }
      return ingredients;
    },);
  }
  return ingredientStream!;
}

This:

  1. Gets a stream of ingredients.
  2. Maps each ingredient in the stream to a stream of model ingredients
  3. Converts each ingredient in the list to a model ingredient.

Finding Recipes and Ingredients

The find methods are a bit easier, but they still need to convert each database class to a model class.

Replace // TODO: Add findRecipeById() with:

@override
Future<Recipe> findRecipeById(int id) async {
    // 1
    final ingredients = await findRecipeIngredients(id);
    // 2
    return _recipeDao.findRecipeById(id).then((listOfRecipes) =>
        dbRecipeToModelRecipe(listOfRecipes.first, ingredients));
}
  1. Find all of the ingredients for the given recipe.
  2. Since findRecipeById() returns a list, just take the first one and convert it.

Look for // TODO: Add findAllIngredients() and replace it with:

@override
Future<List<Ingredient>> findAllIngredients() {
  return _ingredientDao.findAllIngredients().then<List<Ingredient>>(
    (List<DbIngredientData> dbIngredients) {
      final ingredients = <Ingredient>[];
      for (final ingredient in dbIngredients) {
        ingredients.add(dbIngredientToIngredient(ingredient));
      }
      return ingredients;
    },
  );
}

This method is almost like watchAllIngredients(), except it doesn’t use a stream.

Finding all the ingredients for a recipe is similar. Replace // TODO: Add findRecipeIngredients() with:

@override
Future<List<Ingredient>> findRecipeIngredients(int recipeId) {
  return _ingredientDao.findRecipeIngredients(recipeId).then(
    (listOfIngredients) {
      final ingredients = <Ingredient>[];
      for (final ingredient in listOfIngredients) {
        ingredients.add(dbIngredientToIngredient(ingredient));
      }
      return ingredients;
    },
  );
}

This method finds all the ingredients associated with a single recipe. Now it’s time to look at inserting recipes.

Inserting Recipes and Ingredients

To insert a recipe, you first insert the recipe itself and then insert all its ingredients. Replace // TODO: Add insertRecipe() with:

@override
Future<int> insertRecipe(Recipe recipe) {
  // 1 
  if (state.currentRecipes.contains(recipe)) {
    return Future.value(0);
  }
  return Future(
    () async {
      // 2
      state =
          state.copyWith(currentRecipes: [...state.currentRecipes, recipe]);
      // 3
      final id =
      await _recipeDao.insertRecipe(
        recipeToInsertableDbRecipe(recipe),
      );
      final ingredients = <Ingredient>[];
      for (final ingredient in recipe.ingredients) {
        // 4
        ingredients.add(ingredient.copyWith(recipeId: id));
      }
      // 5
      insertIngredients(ingredients);
      return id;
    },
  );
}

Here you:

  1. Check to see if the recipe already exists.
  2. Update the state with the new recipe.
  3. Use the recipe DAO to insert a converted model recipe.
  4. Add a copy of the ingredient with the recipe ID for each ingredient.
  5. Insert all the ingredients. You’ll define these next.

Now, it’s finally time to add methods to insert ingredients. Replace // TODO: Add insertIngredients() with:

@override
Future<List<int>> insertIngredients(List<Ingredient> ingredients) {
  return Future(
    () {
      // 1
      if (ingredients.isEmpty) {
        return <int>[];
      }
      final resultIds = <int>[];
      for (final ingredient in ingredients) {
        // 2
        final dbIngredient =
            ingredientToInsertableDbIngredient(ingredient);
        // 3
        _ingredientDao
            .insertIngredient(dbIngredient)
            .then((int id) => resultIds.add(id));
      }
      // 4
      state = state.copyWith(
        currentIngredients: [...state.currentIngredients, ...ingredients]);

      return resultIds;
    },
  );
}

This code:

  1. Checks to make sure you have at least one ingredient.
  2. Converts the ingredient.
  3. Inserts the ingredient into the database and adds a new ID to the list.
  4. Update the state with the new ingredients.

Now, it’s time to add code to delete recipes and ingredients.

Methods for Deleting Recipes and Ingredients

Deleting is much easier. You need to call the DAO methods. Replace // TODO: Add Delete methods with:

@override
Future<void> deleteRecipe(Recipe recipe) {
  if (recipe.id != null) {
    // 1
    final updatedList = [...state.currentRecipes];
    updatedList.remove(recipe);
    state = state.copyWith(currentRecipes: updatedList);
    // 2
    _recipeDao.deleteRecipe(recipe.id!);
    deleteRecipeIngredients(recipe.id!);
  }
  return Future.value();
}

@override
Future<void> deleteIngredient(Ingredient ingredient) {
  if (ingredient.id != null) {
    // 3
    return _ingredientDao.deleteIngredient(ingredient.id!);
  } else {
    return Future.value();
  }
}

@override
Future<void> deleteIngredients(List<Ingredient> ingredients) {
  for (final ingredient in ingredients) {
    if (ingredient.id != null) {
      _ingredientDao.deleteIngredient(ingredient.id!);
    }
  }
  return Future.value();
}

@override
Future<void> deleteRecipeIngredients(int recipeId) async {
  // 4
  final ingredients = await findRecipeIngredients(recipeId);
  // 5
  return deleteIngredients(ingredients);
}

The last method is the only one that’s different. In the code above, you:

  1. Delete the recipe from our state list.
  2. Use the RecipeDao to delete the recipe.
  3. Use the IngredientDao to delete the ingredient.
  4. Find all ingredients for the given recipe ID.
  5. Delete the list of ingredients.

Phew! The hard work is over.

Replacing the Repository

Now, you just have to replace your memory repository with your shiny new db repository.

Open providers.dart. Add the import:

import 'data/repositories/db_repository.dart';

and remove the memory_repository.dart import. Change the repositoryProvider from:

final repositoryProvider =
    NotifierProvider<MemoryRepository, CurrentRecipeData>(() {
  return MemoryRepository();
});

to:

final repositoryProvider =
    NotifierProvider<DBRepository, CurrentRecipeData>(() {
      throw UnimplementedError();
});

Open main.dart. Add the import:

import 'data/repositories/db_repository.dart';

Delete the import statement: import 'data/memory_repository.dart’; if present.

Add this after the sharedPrefs:

final repository = DBRepository();
await repository.init();

Then, add the repository to the overrides:

repositoryProvider.overrideWith(() { return repository; }),

Running the App

Stop the running app, build and run. Try performing searches, adding bookmarks, checking the groceries and deleting bookmarks. It will work just the same as with MemoryRepository, with the added value that bookmarks are persisted across application runs. Try running on Mac, Windows or the web.

Congratulations! Now, your app is using all the power provided by Drift to store data in a local database!

Key Points

  • Databases persist data locally to the device.
  • Data stored in databases are available after the app restarts.
  • The Drift package is more powerful, easier to set up and you interact with the database via Dart classes that have clear responsibilities.

Where to Go From Here?

To learn about:

In the next section, you’ll learn about Firebase and how to use Firestore Database.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.