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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

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%';

Adding Data

You can add data using the INSERT statement:

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

Deleting Data

To delete data, use the DELETE statement:

DELETE FROM Customers WHERE id = 1;

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;

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.

Defaqd 0 Pojebt 9 Biv Begje Cuyumv 7 Gigult 4 Tur Zotfu Kotuly 0 Kanajf 9 Joq Yutxu Fajutuw Uygmozeejbt Lexaxata Qunenayohn

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

Using the Drift Library

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

drift: ^2.13.1
drift_dev: ^2.13.2

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.

Vizto PeqoseneIwrefgif PulorakaEzvupmoj VjGetila CjIkbfemeuxh WuboroWuo EfjzihuoydGai Nugbi Guxebuno CerowuBapebune

import 'package:drift/drift.dart';
import 'connection.dart' as impl;
import '../models/models.dart';
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

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.

// 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()();

}

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()();

}

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.

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

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

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());
}
(select(dbRecipe)..where((tbl) => tbl.id.equals(id))).get();
into(dbRecipe).insert(recipe)
// 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());
}

Generating the Part File

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

dart run build_runner build --delete-conflicting-outputs

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

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

Updating watchAllRecipes()

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

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

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:

Jelatenuwm FeqognWaqiceluqq KxVefiperild

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';
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();
  }
}

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;
    },
  );
}
@override
Stream<List<Recipe>> watchAllRecipes() {
  recipeStream ??= _recipeDao.watchAllRecipes();
  return recipeStream!;
}
@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!;
}

Finding Recipes and Ingredients

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

@override
Future<Recipe> findRecipeById(int id) async {
    // 1
    final ingredients = await findRecipeIngredients(id);
    // 2
    return _recipeDao.findRecipeById(id).then((listOfRecipes) =>
        dbRecipeToModelRecipe(listOfRecipes.first, ingredients));
}
@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;
    },
  );
}
@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;
    },
  );
}

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

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

Replacing the Repository

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

import 'data/repositories/db_repository.dart';
final repositoryProvider =
    NotifierProvider<MemoryRepository, CurrentRecipeData>(() {
  return MemoryRepository();
});
final repositoryProvider =
    NotifierProvider<DBRepository, CurrentRecipeData>(() {
      throw UnimplementedError();
});
import 'data/repositories/db_repository.dart';
final repository = DBRepository();
await repository.init();
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.

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:

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now