Chapter 10

Step9: お気に入りリストをつくろう(Sqflite)

すぎっと@フルペラットエンジニア
すぎっと@フルペラットエンジニア
2022.02.07に更新

Step9 の概要

Step9 では、sqflite という SQLiteのパッケージを使用して、お気に入りリスト機能を作成します。

本章で学べること

  • sqfliteの使い方
  • 複数のProviderの扱い方
  • ModalBottomSheetの扱い方

お気に入りリストをのUIをつくろう

まずはベースとなるUIから作成します。
お気に入りリストはメインのポケモン一覧を再利用して機能を追加してみようと思います。
PokeList ウィジェットは全てのポケモンを表示するListViewでした。これを改良してみましょう。
PokeListの実装が増えることになりそうなので、まずはPokeListを独立したファイルに切り出して、扱いやすくしておきましょう。ファイルごとに役割がシンプルにまとまっている方がなにかと扱いやすいと思います。
以下の poke_list.dart を作ります。main.dart からは同じ実装を削除し、 poke_list をimport して対応します。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import './models/pokemon.dart';
import './const/pokeapi.dart';
import './poke_list_item.dart';

class PokeList extends StatefulWidget {
  const PokeList({Key? key}) : super(key: key);
  
  _PokeListState createState() => _PokeListState();
}

class _PokeListState extends State<PokeList> {
  static const int more = 30;
  int pokeCount = more;
  
  Widget build(BuildContext context) {
    return Consumer<PokemonsNotifier>(
      builder: (context, pokes, child) => ListView.builder(
        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
        itemCount: pokeCount + 1, // pokeMaxId,
        itemBuilder: (context, index) {
          if (index == pokeCount) {
            return OutlinedButton(
              child: const Text('more'),
              onPressed: () => {
                setState(
                  () {
                    pokeCount = pokeCount + more;
                    if (pokeCount > pokeMaxId) {
                      pokeCount = pokeMaxId;
                    }
                  },
                )
              },
            );
          } else {
            return PokeListItem(
              poke: pokes.byId(index + 1),
            );
          }
        },
      ),
    );
  }
}

では、PokeList ウィジェットを改良してお気に入りリスト機能を追加してみようと思います。
お気に入りリストというデータを保持したりするのは一旦後回しにして、適当なダミーデータを定義して、UIを作り込み、その後で実際のデータを流し込もうと思います。アプリに機能を追加していくときはまず「データ設計」をやったのち、各機能レイヤーごとに仕上げていくのがおすすめです。
ではお気に入りリストのデータ設計をします。

class Favorite {
  final int pokeId;

  Favorite({
    required this.pokeId,
  });
}

IDだけでとりあえずスタートしましょう。お気に入りリストはこの Favoriteを使って、 List<Favorite> でOKです。これは、お気に入りリストに登録されるデータについてはただのリストで十分であるという見込みがあるからです。検索性能など考えるとこのあたりもデータ設計で考慮しておいた方が良いです。(お気に入りリストからXXXの条件を満たすものを探す、など。)

では、PokeList の拡張ですが、お気に入り表示モードと通常モードを切り替えるような機能にしてみようと思います。
PokeList では、全ポケモンを順番に表示するため、ListViewのindexをそのまま使って PokeListItem にポケモン情報を提供していました。

return Consumer<PokemonsNotifier>(
      builder: (context, pokes, child) => ListView.builder(
        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
        itemCount: pokeCount + 1,
        itemBuilder: (context, index) {
          if (index == pokeCount) {
            // 省略
          } else {
            return PokeListItem(
              poke: pokes.byId(index + 1),  // <- ここ
            );
          }
        },
      ),
    );

この辺りのロジックを切り出し、お気に入り表示モードと通常モードで切り替えるようにしてみようと思います。
まずは表示個数の管理方法を見直します。以前は 「いくつ表示するか」を state にしていました。このままでも悪くはないのですが、もともとの実装では setState() 内にロジック(押すたびに30増やしつつ、最大数(898個) 以内にする)が入っていたので、ちょっと扱いにくいです。
これを 「いま何ページ目か」に変えることでシンプルにしてみます。

class _PokeListState extends State<PokeList> {
  static const int pageSize = 30;
  int _currentPage = 1;

  // 表示個数
  int itemCount() {
    int ret = _currentPage * pageSize;
    if (ret > pokeMaxId) {
      ret = pokeMaxId;
    }
    return ret;
  }

  
  Widget build(BuildContext context) {
    return Consumer<PokemonsNotifier>(
      builder: (context, pokes, child) => ListView.builder(
        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
        itemCount: itemCount() + 1,
        itemBuilder: (context, index) {
          if (index == itemCount()) {
            return OutlinedButton(
              child: const Text('more'),
              onPressed: () => {
                setState(() => _currentPage++),
              },
            );
          } else {
            return PokeListItem(
              poke: pokes.byId(index + 1),
            );
          }
        },
      ),
    );
  }
}

こうすると、setStateはページ番号を更新するだけでよくなり、表示個数のロジックが itemCount() 関数内に集まりました。
ここで、itemCount()関数が_currentPageに依存しているところが気になる方もいるかもしれないですね。いい目をお持ちだと思います。itemCount()の独立性を意識するなら依存は注入した方が良いです。
ということで、以下のように変更します。

  int itemCount(int page) {
    int ret = page * pageSize;
    if (ret > pokeMaxId) {
      ret = pokeMaxId;
    }
    return ret;
  }

呼び出し側では itemCount(_currentPage) という形で与えてあげます。
このほうが、itemCount() が シンプルな関数になるので、テストもしやすくなり、いい感じです。
もっとこだわって pageSizeやpokeMaxIdもきっちりパラメーターにしてあげても良いです。ただ、めんどくさいのでやってません。多少の手抜きも必要です。ただ、この辺りは "わかってやっている" と "わからずこうなっている" では大違いなので、実装の時には気にするようにしましょう。
では、ここにお気に入りリスト表示モードを追加してみます。

  bool isFavoriteMode = false;
  int _currentPage = 1;

  // 表示個数
  int itemCount() {
    int ret = _currentPage * pageSize;
    if (isFavoriteMode && ret > favMock.length) {
      ret = favMock.length;
    }
    if (ret > pokeMaxId) {
      ret = pokeMaxId;
    }
    return ret;
  }

favMock はあとで消しますが、一旦ダミー用のデータとしてmodel の近くに定義しました。

List<Favorite> favMock = [
  Favorite(pokeId: 1),
  Favorite(pokeId: 4),
  Favorite(pokeId: 7),
];

isFavoriteMode が有効なときは itemCount の上限を favMockのサイズにしてあります。これにより、お気に入りリスト数分だけの表示に切り替えることができますね。
続いて、各 PokeListItem に渡すポケモン情報も切り替えてみましょう。

int itemId(int index) {
    int ret = index + 1; // 通常モード
    if (isFavoriteMode) {
      ret = favMock[index].pokeId;
    }
    return ret;
}

こんな関数を作成し、

return PokeListItem(
        poke: pokes.byId(itemId(index)),
      );

このように渡してあげることで、 isFavoriteMode のフラグによって提供されるポケモン情報が切り替わることがわかると思います。
実際に isFavoriteMode = true にしてみるとこうなります。

もうひと踏ん張り実装をこだわっておきましょう。
上記のお気に入りリスト表示モードにすることで1つ新しい課題が見つかりました。それは、"more" のボタンがいつでも押せると言うことです。つまりは page が増やしたい放題です。見た目は変わらないのに、です。
もうこれ以上表示するものはないよ、というのを認識してボタンを無効化しましょう。

bool isLastPage(int page) {
    if (isFavoriteMode) {
      if (_currentPage * pageSize < favMock.length) {
        return false;
      }
      return true;
    } else {
      if (_currentPage * pageSize < pokeMaxId) {
        return false;
      }
      return true;
    }
  }

こんな関数を実装し、

OutlinedButton(
  child: const Text('more'),
  onPressed: isLastPage(_currentPage)
      ? null
      : () => {
            setState(() => _currentPage++),
          },
);

以下のように onPressed に null を渡します。 onPressed に null を渡すと、ボタンは無効化されます。これはボタンの仕様です。
ではこれでお気に入りリスト表示モードが実装できたので、モードの切り替えができるようにします。そうですね、ListViewの上に細いメニュー表示領域を作って、そこにアイコンボタンでも置いてみようと思います。
縦に並べるので Column で ListView と IconButton を並べたらいいのかな?やってみましょう。

return Column(
        children: [
          IconButton(icon: const Icon(Icons.star), onPressed: () => {}),
          Consumer<PokemonsNotifier>(
            builder: (context, pokes, child) => ListView.builder(
              // ....
            ),
          ),
        ],
      );

こんな感じかな?とやると、以下のエラーがでます。

══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞═════════════════════════════════════════════════════════
The following assertion was thrown during performResize():
Vertical viewport was given unbounded height.
Viewports expand in the scrolling direction to fill their container. In this case, a vertical
viewport was given an unlimited amount of vertical space in which to expand. This situation
typically happens when a scrollable widget is nested inside another scrollable widget.
If this widget is always nested in a scrollable widget there is no need to use a viewport because
there will always be enough vertical space for the children. In this case, consider using a Column
instead. Otherwise, consider using the "shrinkWrap" property (or a ShrinkWrappingViewport) to size
the height of the viewport to the sum of the heights of its children.

The relevant error-causing widget was:
  ListView

......(続く)

これは何かというと、Columnの中にListView を入れたとき、ListViewの高さが決められなくなってしまった、というものです。

もとのListViewの場合、Scaffoldの bodyにそのまま適用されていたので、高さはデバイスサイズから計算されました。ListViewのようなスクロールするコンテンツの場合、高さが決まらないとなにもできません。例えば、高さ100のところに高さ20のものを100個表示して欲しい、という状況なら、5個づつ表示してあとはスクロールしてやればよい、というのがわかりますが、高さが決まらない場合、「え、いくつ表示できたらいいの?」となってしまい、アウトです。
ではこの場合はどうすれば良いのかというと、ListViewの親に「高さが決まる何か」を挟んでやります。
高さを決めるものといえば SizedBoxだ〜と、SizedBoxを使ってもらっても構いませんが、高さはどうやって指定しますか? MediaQueryからなんやかんや計算しますか?デバイスの差をどうやって埋めたらいいんだろう・・・また難しくなってきましたね。
こういう時に便利なWidgetがあります。それが Expanded です。Expandedは残りの領域を可能な限り全部取った時の高さを自動で計算してくれます。相対的に高さを計算するWidgetですね。少し前に登場した Spacer にちょっと似ています。

return Column(
  children: [
    IconButton(icon: const Icon(Icons.star), onPressed: () => {}),
      Expanded(
          child: Consumer<PokemonsNotifier>(
            builder: (context, pokes, child) => ListView.builder(
              // ....
            ),
          ),
        ),
      ],
    );

これでエラーが解消されました。

ちょっとまだなんだかダサいので、もうちょっと凝ってみましょう。
まず右上に寄せます。アイコンも白抜きに変えます。

Align(
  alignment: Alignment.topRight,
  child: IconButton(icon: const Icon(Icons.star_outline), onPressed: () => {}),
),

次に、上部の領域がちょっと大きすぎるので、小さくします。
Alignをやめ、Containerにしてheightを指定します。Containerにもalignment機能はあるので、そのまま変更できます。
Before → Afterがわかりやすくなるよう、色をつけてみました。これは説明用です。

些細な違いですが、あんまり主張が激しいのは好きではないのでこういうメニューは小さめが好きです。
次に、星マークのクリックでお気に入りリストモードになるようにします。

Container(
  height: 24,
  alignment: Alignment.topRight,
  child: IconButton(
    padding: const EdgeInsets.all(0),
    icon: const Icon(Icons.star_outline),
    onPressed: () => {setState(() => isFavoriteMode = !isFavoriteMode)},
  ),
),

これで、以下のように切り替えることができました。

お気に入りリスト表示モードかどうかがパッとみてわかるように、モードが有効になっているときは星マークに色をつけてみましょう。

Container(
  height: 24,
  alignment: Alignment.topRight,
  child: IconButton(
    padding: const EdgeInsets.all(0),
    icon: isFavoriteMode
        ? const Icon(Icons.star, color: Colors.orangeAccent)
        : const Icon(Icons.star_outline),
    onPressed: () => {setState(() => isFavoriteMode = !isFavoriteMode)},
  ),
),

いい感じですね ♪
とはいったものの、この星マークを押したら色がつくというアクションって、「お気に入りモードにする」というよりも「お気に入りに登録する」の感覚のほうが一般的なのではないかな、と思いました。
そこで、以下のようなデザインに変更してみましょう(これまでのはなんだったんだ)

Twitterのコレですね。デザインを組む時に有名なアプリの使用感を踏襲しておくと、(私のような)デザインの素人でもそれっぽく仕上げることができます。
さすがに同じアイコンはないので、似たような雰囲気のものを探してきます。

Google Fonts

icon: const Icon(Icons.auto_awesome_outlined),

これにします。

ではこのボタンをクリックすると、ボトムシートが出てくるようにしてみましょう。

https://api.flutter.dev/flutter/material/showModalBottomSheet.html

一旦説明を飛ばして一気に仕上げてみました。

では順に見ていきましょう。

Container(
    height: 24,
    alignment: Alignment.topRight,
    child: IconButton(
      padding: const EdgeInsets.all(0),
      icon: const Icon(Icons.auto_awesome_outlined),
      onPressed: () async {
        var ret = await showModalBottomSheet<bool>(
          context: context,
          shape: const RoundedRectangleBorder(
            borderRadius: BorderRadius.only(
              topLeft: Radius.circular(40),
              topRight: Radius.circular(40),
            ),
          ),
          builder: (BuildContext context) {
            return ViewModeBottomSheet(
              favMode: isFavoriteMode,
            );
          },
        );
        if (ret != null && ret) {
          changeMode(isFavoriteMode);
        }
      },
    ),
  ),

まずは左上のアイコンをクリックした時の挙動を showModalBottomSheet に変更します。
showModalBottomSheet には bool の型指定をします。これによって、showModalBottomSheetの終了時にbool値を受け取ることができます。この処理は非同期になりますので、awaitで受け、有効な場合にモードを変更します。
ボトムシートの角が丸くなっているTwitterのデザインはとてもかわいいので、そこも真似しています。showModalBottomSheetのshapeプロパティにRadiusを指定しています。
では次にボトムシートの内部(ViewModeBottomSheetウィジェット)をみてみましょう。
これは完全にTwitterのUIを参考にしました。

class ViewModeBottomSheet extends StatelessWidget {
  const ViewModeBottomSheet({
    Key? key,
    required this.favMode,
  }) : super(key: key);
  final bool favMode;

  String mainText(bool fav) {
    if (fav) {
      return 'お気に入りのポケモンが表示されています';
    } else {
      return 'すべてのポケモンが表示されています';
    }
  }

  String menuTitle(bool fav) {
    if (fav) {
      return '「すべて」表示に切り替え';
    } else {
      return '「お気に入り」表示に切り替え';
    }
  }

  String menuSubtitle(bool fav) {
    if (fav) {
      return '全てのポケモンが表示されます';
    } else {
      return 'お気に入りに登録したポケモンのみが表示されます';
    }
  }

  
  Widget build(BuildContext context) {
    return Container(
      height: 300,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Center(
        child: Column(
          children: <Widget>[
            Container(
              height: 5,
              width: 30,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(20),
                color: Theme.of(context).backgroundColor,
              ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
              child: Text(
                mainText(favMode),
                style: const TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
            ListTile(
              leading: const Icon(Icons.swap_horiz),
              title: Text(
                menuTitle(favMode),
              ),
              subtitle: Text(
                menuSubtitle(favMode),
              ),
              onTap: () {
                Navigator.pop(context, true);
              },
            ),
            OutlinedButton(
              style: OutlinedButton.styleFrom(
                shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(40)),
              ),
              child: const Text('キャンセル'),
              onPressed: () => Navigator.pop(context, false),
            ),
          ],
        ),
      ),
    );
  }
}

長くなっていますが、内容はシンプルです。
まず、ボトムシートのトップにContainerで作ったアンカーをつけています。これです。こういう小技も大事ですね。

Container(
  height: 5,
  width: 30,
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(20),
    color: Theme.of(context).backgroundColor,
  ),
),

あとはモードに合わせた説明文を出しているだけです。
ポイントは以下のようにNavigatorを使って処理を抜ける際に 第二引数で bool 値を返しているところです。この値を showModalBottomSheetの呼び出し側で受け取ることができます。

onTap: () {
          Navigator.pop(context, true);
        },

お気に入りリストを状態管理しよう

かなりデザインに振り切ってしまいましたが、次にお気に入りリストを状態管理下におきましょう。これはもう慣れたものですね。ChangeNotifierです。

class FavoritesNotifier extends ChangeNotifier {
  final List<Favorite> _favs = [];

  List<Favorite> get favs => _favs;

  void add(Favorite fav) {
    favs.add(fav);
    notifyListeners();
  }

  void delete(Favorite fav) {
    var res = favs.remove(fav);
    if (res) {
      notifyListeners();
    }
    // エラー処理あった方が良い
  }
}

とりあえず追加と削除を備えた FavoritesNotifier をつくりました。
次にこれを ChangeNotifierProviderをつかって提供します。MultiProvider便利ですね。

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final SharedPreferences pref = await SharedPreferences.getInstance();
  final themeModeNotifier = ThemeModeNotifier(pref);
  final pokemonsNotifier = PokemonsNotifier();
  final favoritesNotifier = FavoritesNotifier();
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider<ThemeModeNotifier>(
          create: (context) => themeModeNotifier,
        ),
        ChangeNotifierProvider<PokemonsNotifier>(
          create: (context) => pokemonsNotifier,
        ),
        ChangeNotifierProvider<FavoritesNotifier>(
          create: (context) => favoritesNotifier,
        ),
      ],
      child: const MyApp(),
    ),
  );
}

次にPokeList でこれをうけとるために Consumerで包みます。

return Consumer<FavoritesNotifier>(
      builder: (context, favs, child) => Column(
        children: [
          // ...
);

あとは受け取った favs を使って、 ダミーで用意していた favMock を置き換えるだけです。置き換えが終わったら、favMockはもう使わないので削除しておいてください。もう一つ気配りとして、お気に入りリストが空の可能性もあるので、以下のように表示個数がゼロの時はListViewを使わず、データがないことを表示するようにしてみました。

child: Consumer<PokemonsNotifier>(
  builder: (context, pokes, child) {
          if (itemCount(favs.favs.length, _currentPage) == 0) {
            return const Text('no data');
          } else {
            return ListView.builder(
              // .....

これで表示側の準備はOKです。お気に入りリストへの登録をやりましょう。お気に入りへの登録はとりあえず PokeList 画面ではなく、PokeDetail画面で実施するようにします。

class PokeDetail extends StatelessWidget {
  const PokeDetail({Key? key, required this.poke}) : super(key: key);
  final Pokemon poke;
  
  Widget build(BuildContext context) {
    return Consumer<FavoritesNotifier>(
      builder: (context, favs, child) => Scaffold(
        body: SafeArea(
         // ....

Consumerで受け渡します。
次に、すでにある「戻る」ボタンの行の右端にお気に入り登録ボタンを作成します。

ListTile(
  leading: IconButton(
    icon: const Icon(Icons.arrow_back),
    onPressed: () => Navigator.pop(context),
  ),
  trailing: IconButton(
    icon: const Icon(Icons.star_outline),  // <- お気に入りボタン
    onPressed: () => {},
  ),
),

ここからは少し前につくってボツになった実装を再利用して、お気に入り登録済みなら色付きのスターアイコンにするようにしましょう。
このお気に入りボタンですが、トグルの動きになっているとわかりやすいですね。お気に入り登録済みなら、解除。未登録なら登録、という具合です。
この辺りのロジックはChangeNotifier側でもってしまった方が何かと楽なので

  • トグルメソッド
  • お気に入り登録済みチェックメソッド

の2つをChangeNotifier側に実装します。

class FavoritesNotifier extends ChangeNotifier {
  final List<Favorite> _favs = [];

  List<Favorite> get favs => _favs;

  void toggle(Favorite fav) {
    if (isExist(fav.pokeId)) {
      delete(fav.pokeId);
    } else {
      add(fav);
    }
  }

  bool isExist(int id) {
    if (_favs.indexWhere((fav) => fav.pokeId == id) < 0) {
      return false;
    }
    return true;
  }

  void add(Favorite fav) {
    favs.add(fav);
    notifyListeners();
  }

  void delete(int id) {
    favs.removeWhere((fav) => fav.pokeId == id);
    notifyListeners();
    // エラー処理あった方が良い
  }
}

あとはこれを使って実装すればOKです。

trailing: IconButton(
            icon: favs.isExist(poke.id)
                ? const Icon(Icons.star, color: Colors.orangeAccent)
                : const Icon(Icons.star_outline),
            onPressed: () => {
              favs.toggle(Favorite(pokeId: poke.id)),
            },

これで無事、お気に入りリストが作成できました。

お気に入りリストを保存しよう

あとはお気に入りリストの永続化ですね。
永続化の方法はいくつかあるのですが、テーマ設定同様 SharedPreferences にしても良いですし、ローカルDBを作っても良いです。ローカルDBとしては SQLite を使った sqflite が有名です。

Persistence

永続化に関する公式のページでも sqflite が説明されています。
正直、お気に入りリストはまだ ID しかないので、sqflite を使う必要性は皆無なのですが、練習を兼ねて sqflite を使用してみます。
まずはパッケージを入れます。

  • sqflite
  • path

の2つを入れます。sqfliteはデータベースのファイルを操作するためのパッケージです。pathはデータベースのファイルをアプリケーションのファイルとして適切な場所に保存するために使用します。

ではデータベースへのアクセスを行うファイルを新しく作成します。
'~/lib/db/favorites.dart' として作成しました。
まずは database ファイルを作成するメソッドを定義します。

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/favorite.dart';

class FavoritesDb {
  static Future<Database> openDb() async {
    return await openDatabase(
      join(await getDatabasesPath(), 'poke_favs.db'),
      onCreate: (db, version) {
        return db.execute(
          'CREATE TABLE favorites(id INTEGER PRIMARY KEY)',
        );
      },
      version: 1,
    );
  }
}

sqflite パッケージにある openDatabase メソッドを使用しています。
getDtatabasePath メソッドも sqflite が提供するもので、iOS、Android それぞれに適したファイル置き場のパスを返してくれるものです。そのパスとデータベースファイル名を join メソッドを使ってデータベースファイルへのパスに変換します。ちなみに、join メソッドは path パッケージのものです。
onCreateは、データベースファイルが見つからない時に呼び出される関数を定義するもので、DBファイルがあれば開き、なければ新規作成する、という実装をするために使います。
db.execute の中は SQLite のクエリ文です。
この実装ですが、ファイル名の poke_favs.db と、テーブル名の favorites を文字列中に直接記述しています。別にこれでも良いのですが、定数として取り出しておいた方が良いかもしれません。

static Future<Database> openDb() async {
    return await openDatabase(
      join(await getDatabasesPath(), favFileName),
      onCreate: (db, version) {
        return db.execute(
          'CREATE TABLE $favTableName(id INTEGER PRIMARY KEY)',
        );
      },
      version: 1,
    );
  }

これと

// ~/lib/const/db.dart
const String favFileName = "poke_favs.db";
const String favTableName = "favorites";

これで分けておいた方が良いです。
データベースを開くメソッドを定義する際、static をつけています。管理用に FavoritesDb クラスでまとめたとき、staticがないメソッドはそのクラスのインスタンスを生成しなければつかうことができません。この辺りはDart(に限らず多くの言語の)クラスのルールです。
インスタンスを生成しなければ使えないというのはちょっと煩わしいので、staticをつけることによって、クラスでまとめた関数をインスタンスなしに呼び出せるようになります。
ざっくりコードを書くと、呼び出し側で

final FavoritesDb favDb = FavoritesDb();
favDb.openDb()

と呼び出すのではなく、

FavoritesDb.openDb();

と呼び出したいということです。今回はFavoritesDb内で管理したいメンバ変数がないので特に困ることもありません。
また、sqflite の使いかたを検索すると、 openDatabase() メソッドが返す Future<Database> 型のインスタンスを保持しておき、2回目、3回目のデータベースメソッドの呼び出しで利用しているような実装を見かけます。(sqflite singletonや sqflite instance などで検索するといろいろ出てきます)
しかし、これは不要です。
openDatabase() メソッド自体がシングルトンの機能を持っているので、2回目以降も気にせず openDatabase() メソッドを呼び出せば、前回作成したインスタンスを返してくれるようになっています。この辺りは sqflite 側で実装されているので、自前で実装する必要はありません。

出典: sqflite の openDatabase() メソッドに以下の記述があります。

/// When [singleInstance] is true (the default), a single database instance is
/// returned for a given path. Subsequent calls to [openDatabase] with the
/// same path will return the same instance, and will discard all other
/// parameters such as callbacks for that invocation.

データベースの処理といえば CRUD処理です。

  • CREATE
  • READ
  • UPDATE
  • DELETE

ですね。

今回はUPDATEは使わないので実装しませんが、基本的にはこの4つのメソッドを作っておくと取り回しが良いです。

  • CREATE
static Future<void> create(Favorite fav) async {
    var db = await openDb();
    await db.insert(
      favTableName,
      fav.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

保存用の toMap は Favorite クラスに実装しておきます。

Map<String, dynamic> toMap() {
    return {
      'id': pokeId,
    };
  }
  • READ
static Future<List<Favorite>> read() async {
    var db = await openDb();
    final List<Map<String, dynamic>> maps = await db.query(favTableName);
    return List.generate(maps.length, (index) {
      return Favorite(
        pokeId: maps[index]['id'],
      );
    });
  }
  • DELETE
static Future<void> delete(int pokeId) async {
    var db = await openDb();
    await db.delete(
      favTableName,
      where: 'id = ?',
      whereArgs: [pokeId],
    );
  }

こういうところで先程定義した "favTableName" という定数が活躍しますね。実装の過程で名前の変更が必要になった時などサクッと対応できます。
ではこのデータベースを使って FavoritesNotifier と連携します。
すでに実装済みのaddメソッドとdeleteメソッド内でデータベースへの操作もセットでおこないます。

void add(Favorite fav) async {
    favs.add(fav);
    await FavoritesDb.create(fav);
    notifyListeners();
  }

void delete(int id) async {
  favs.removeWhere((fav) => fav.pokeId == id);
  await FavoritesDb.delete(id);
  notifyListeners();
  // エラー処理あった方が良い
}

さて、これでいいのでしょうか・・・。
メモリ上で管理されている favs に対して add / delete をしつつ、同時にデータベース側に対しても add / delete をする。これは実はあまり良くありません。
例えば、Db側だけ何かのトラブルが発生し、書き込みに失敗したらどうでしょう?メモリ上とデータベース内で情報に齟齬が出てしまいます。一度齟齬が生まれてしまうと、さまざまなエラー処理が適切に実装されていない限り、バグとなって出てきてしまいます。
”最新の情報は誰が持つか?" ということを考えるのが良いです。この実装では最新の情報をメモリ上の favs と データベースの両方が持っています。片方にするか、どちらを信じるかをきっちり決めるべきです。
ということで、ローカルのfavsに対する add / delete は実装せず、一旦データベースに書き込んでからそれを "同期する" という実装に変更します。

void syncDb() async {
  FavoritesDb.read().then(
    (val) => _favs
      ..clear()
      ..addAll(val),
  );
  notifyListeners();
}

void add(Favorite fav) async {
  await FavoritesDb.create(fav);
  syncDb();
}

void delete(int id) async {
  await FavoritesDb.delete(id);
  syncDb();
}

この "同期する" を、Notifierクラスのコンストラクタで実行しておくことで、初期化処理を賄うこともできます。

class FavoritesNotifier extends ChangeNotifier {
  final List<Favorite> _favs = [];

  FavoritesNotifier() {
    syncDb();
  }
  // .....
}

これでデータベースの対応は完了です。簡単でしたね。