🍂

【Dart/Flutter】Flutter Apprenticeのテクニック抜粋 - 備考2

2021/11/17に公開

無料で素晴らしい教育資料Flutter Apprenticeの中で、
個人的に覚えておきたいテクニックをソースコード部分と共に抜粋したものとなります。
順を追っての説明はありませんので、詳細はFlutter Apprentice にてご確認ください。
何か思い出す際等に、お役立ちできれば幸いです。

関連記事

【Dart/Flutter】Flutter Apprenticeのテクニック抜粋

【Dart/Flutter】Flutter Apprenticeのテクニック抜粋 - 備考1

【Dart/Flutter】Flutter Apprenticeのテクニック抜粋 - 備考2

【Dart/Flutter】Flutter Apprenticeのテクニック集

はじめに

以下、範囲大きめなテクニック

httpパッケージ利用の簡単なクラス

import 'package:http/http.dart';

const String apiKey = '<Your Key>';
const String apiId = '<your ID>';
const String apiUrl = 'https://api.edamam.com/search';

class RecipeService {
  Future getData(String url) async {
    print('Calling uri: $url');
    final response = await get(Uri.parse(url));
    if (response.statusCode == 200) {
      return response.body;
    } else {
      print(response.statusCode);
    }
  }

  Future<dynamic> getRecipes(String query, int from, int to) async {
    final recipeData = await getData(
        '$apiUrl?app_id=$apiId&app_key=$apiKey&q=$query&from=$from&to=$to');
    return recipeData;
  }
}

ChopperによるGet実装

lib/network/model_response.dart

/// 成功した応答またはエラーのいずれかを保持する汎用応答クラス
/// 必須ではありませんが、サーバーが返す応答の処理を容易にします
abstract class Result<T> {}

/// たとえば、これはJSONデータを保持
class Success<T> extends Result<T> {
  final T value;

  Success(this.value);
}

/// 間違った資格情報を使用したり、承認なしでデータをフェッチしようとしたりするなど、
/// HTTP呼び出し中に発生するエラーがモデル化
class Error<T> extends Result<T> {
  final Exception exception;

  Error(this.exception);
}

lib/network/recipe_service.dart

import 'package:chopper/chopper.dart';

import 'model_converter.dart';
import 'model_response.dart';
import 'recipe_model.dart';

/// 以下のコマンドで作成
/// flutter pub run build_runner build --delete-conflicting-outputs
part 'recipe_service.chopper.dart';

/// 自分でアカウント作成し、取得
// https://www.raywenderlich.com/books/flutter-apprentice/v2.0/chapters/11-networking-in-flutter
const String apiKey = '<Your Key Here>';
const String apiId = '<Your Id here>';

/// httpパッケージ利用時と異なり末尾「/search」が削除された
const String apiUrl = 'https://api.edamam.com';

/// 【アノテーション】
/// 「chopper_generator: ^4.0.1」によるpartファイル作成
/// このファイル名の末尾に「.chopper.dart」と付くファイルを作成
/// ChopperServiceクラスを継承したabstractクラスを定義する
()
abstract class RecipeService extends ChopperService {
  /// ①ネットワーク呼び出しを行うための汎用インターフェースを定義
  /// ※APIキーをリクエストに追加したり、
  ///  レスポンスをデータオブジェクトに変換したりするなどのタスクを実行する、
  ///  実際のコードはありません。これはコンバーターとインターセプターの仕事
  /// 【アノテーション】
  /// GETリクエストであることをジェネレータに通知するアノテーション
  /// 他には、@Post、@Put、@Deleteが存在
  (path: 'search') // 末尾の'/search'はここで指定

  /// httpパッケージ利用での、
  /// Future getData(String url)と、
  /// Future<dynamic> getRecipes(String query, int from, int to)が、
  /// 合わさった感じ
  Future<Response<Result<APIRecipeQuery>>> queryRecipes(
    /// 【アノテーション】
    /// @Queryアノテーションを使用して、query文字列fromとto整数を受け入れ
    /// →中身は、ジェネレータがpartファイルに作成
    ('q') String query,
    ('from') int from,
    ('to') int to,
  );

  /// ③別途ModelConverter(リクエストとレスポンスの変換)と、インターセプター
  /// を準備し、それを利用
  static RecipeService create() {
    /// クライアントを作成し、自動作成する「_$RecipeService(client)」に渡す
    final client = ChopperClient(
      baseUrl: apiUrl,

      /// 2つのインターセプターを指定
      /// 作成した_addQuery()と、HttpLoggingInterceptor
      /// ※HttpLoggingInterceptorはログを出す用
      interceptors: [_addQuery, HttpLoggingInterceptor()],

      /// 作成したコンバータを指定
      converter: ModelConverter(),

      /// JsonConverterエラーをデコード
      errorConverter: const JsonConverter(),

      /// ジェネレータスクリプトの実行時に作成されるサービスを定義
      services: [
        _$RecipeService(),
      ],
    );

    /// 生成されたサービスのインスタンスを返します
    return _$RecipeService(client);
  }
}

/// ②IDとキーを自動的に含めるインターセプター
/// 基本的なクエリに追加して、何かクエリを追加が必要な場合に必要
Request _addQuery(Request req) {
  /// 既存のRequestパラメーターからのキーと値のペアを取得
  final params = Map<String, dynamic>.from(req.parameters);

  /// 必要なものを追加
  params['app_id'] = apiId;
  params['app_key'] = apiKey;

  /// 新規作成として、返す
  return req.copyWith(parameters: params);
}

lib/network/recipe_service.chopper.dart※自動作成ファイル

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'recipe_service.dart';

// **************************************************************************
// ChopperGenerator
// **************************************************************************

// ignore_for_file: always_put_control_body_on_new_line, always_specify_types, prefer_const_declarations
class _$RecipeService extends RecipeService {
  _$RecipeService([ChopperClient? client]) {
    if (client == null) return;
    this.client = client;
  }

  
  final definitionType = RecipeService;

  
  Future<Response<Result<APIRecipeQuery>>> queryRecipes(
      String query, int from, int to) {
    final $url = 'search';
    final $params = <String, dynamic>{'q': query, 'from': from, 'to': to};
    final $request = Request('GET', $url, client.baseUrl, parameters: $params);
    return client.send<Result<APIRecipeQuery>, APIRecipeQuery>($request);
  }
}

lib/network/model_converter.dart

import 'dart:convert';

import 'package:chopper/chopper.dart';

import 'model_response.dart';
import 'recipe_model.dart';

/// リクエストとレスポンスの変換
/// コンバーターをChopperクライアントに接続するには、インターセプターが必要
/// インターセプターは、要求を送信したり応答を受信したりするたびに実行される関数
///  →少し多機能なencode,decode
///
/// Converterをimplementsして実装(必要な関数をオーバーライド)
class ModelConverter implements Converter {
  /// オーバーライド①
  /// リクエストを受け取り、新しいリクエストを返す
  
  Request convertRequest(Request request) {
    /// jsonの処理であることを教える
    final req = applyHeader(
      request,
      contentTypeKey,
      jsonHeaders,
      override: false,
    );

    return encodeJson(req);
  }

  /// JSONのエンコード
  Request encodeJson(Request request) {
    final contentType = request.headers[contentTypeKey];
    if (contentType != null && contentType.contains(jsonHeaders)) {
      /// 基本的には、リクエストのコピーを作成し、返す
      return request.copyWith(body: json.encode(request.body));
    }
    return request;
  }

  /// JSONのエンコード
  /// サーバーの応答は通常文字列であるため、
  /// JSON文字列を解析してAPIRecipeQueryモデルクラスに変換する必要がある
  Response<BodyType> decodeJson<BodyType, InnerType>(Response response) {
    final contentType = response.headers[contentTypeKey];
    var body = response.body;

    /// JSONを扱っていることを確認し、文字列にデコード
    if (contentType != null && contentType.contains(jsonHeaders)) {
      body = utf8.decode(response.bodyBytes);
    }
    try {
      /// 文字列をMapに変換
      final mapData = json.decode(body);
      if (mapData['status'] != null) {
        return response.copyWith<BodyType>(
            body: Error(Exception(mapData['status'])) as BodyType);
      }

      /// Mapを扱いたいモデルクラスに変換
      final recipeQuery = APIRecipeQuery.fromJson(mapData);

      /// 【成功動作】
      /// 扱いたいモデルクラスを持ったSuccess<T>クラスのインスタンスとして返す
      return response.copyWith<BodyType>(
        body: Success(recipeQuery) as BodyType,
      );
    } catch (e) {
      chopperLogger.warning(e);
      return response.copyWith<BodyType>(
        body: Error(e as Exception) as BodyType,
      );
    }
  }

  /// オーバーライド②
  /// 指定された応答を必要な応答に変更(上のdecodeJsonを呼ぶのみ)
  
  Response<BodyType> convertResponse<BodyType, InnerType>(Response response) {
    return decodeJson<BodyType, InnerType>(response);
  }
}

abstractを利用した継承(extends):アプリ起動時のみ情報を保存するChangeNotifierクラス

lib/data/repository.dart

import 'models/models.dart';

/// レシピと材料を追加および削除するメソッドを提供するリポジトリインターフェイス
/// これをextendsさせれば、必要な機能(関数)が分かる
abstract class Repository {
  /// 基本4つ:見つける
  List<Recipe> findAllRecipes();

  Recipe findRecipeById(int id);

  List<Ingredient> findAllIngredients();

  List<Ingredient> findRecipeIngredients(int recipeId);

  /// 応用8つ:追加、削除、初期化、終了
  int insertRecipe(Recipe recipe);

  List<int> insertIngredients(List<Ingredient> ingredients);

  void deleteRecipe(Recipe recipe);

  void deleteIngredient(Ingredient ingredient);

  void deleteIngredients(List<Ingredient> ingredients);

  void deleteRecipeIngredients(int recipeId);

  Future init();
  void close();
}

lib/data/memory_repository.dart

import 'dart:core';

import 'package:flutter/foundation.dart';

import 'models/models.dart';
import 'repository.dart';

/// 材料をメモリに保存する場所
/// アプリを再起動するたびに失われるため、これは一時的な保存
///
/// 用意したabstractクラスのRepositoryを継承
/// (必要な関数をオーバーライドしないとエラー)
class MemoryRepository extends Repository with ChangeNotifier {
  /// レシピと、食材情報を管理
  final List<Recipe> _currentRecipes = <Recipe>[];
  final List<Ingredient> _currentIngredients = <Ingredient>[];

  
  List<Recipe> findAllRecipes() {
    return _currentRecipes;
  }

  
  Recipe findRecipeById(int id) {
    return _currentRecipes.firstWhere((recipe) => recipe.id == id);
  }

  
  List<Ingredient> findAllIngredients() {
    return _currentIngredients;
  }

  
  List<Ingredient> findRecipeIngredients(int recipeId) {
    final recipe =
        _currentRecipes.firstWhere((recipe) => recipe.id == recipeId);

    /// where指定されたレシピIDを持つすべての材料を検索し、リスト化
    final recipeIngredients = _currentIngredients
        .where((ingredient) => ingredient.recipeId == recipe.id)
        .toList();
    return recipeIngredients;
  }

  /// このクラスで管理している変数に追加や削除をする場合は、
  /// notifyListeners()を実行
  
  int insertRecipe(Recipe recipe) {
    _currentRecipes.add(recipe);
    if (recipe.ingredients != null) {
      insertIngredients(recipe.ingredients!);
    }
    notifyListeners();

    /// 新しいレシピのIDを返します。現在は、必要ないので、常に0を返します
    return 0;
  }

  
  List<int> insertIngredients(List<Ingredient> ingredients) {
    if (ingredients.length != 0) {
      _currentIngredients.addAll(ingredients);
      notifyListeners();
    }

    /// 追加されたIDのリストを返します。今のところ空のリスト。
    return <int>[];
  }

  
  void deleteRecipe(Recipe recipe) {
    _currentRecipes.remove(recipe);
    if (recipe.id != null) {
      deleteRecipeIngredients(recipe.id!);
    }
    notifyListeners();
  }

  
  void deleteIngredient(Ingredient ingredient) {
    _currentIngredients.remove(ingredient);
  }

  
  void deleteIngredients(List<Ingredient> ingredients) {
    /// 渡されたリストにあるすべての材料を削除
    _currentIngredients
        .removeWhere((ingredient) => ingredients.contains(ingredient));
    notifyListeners();
  }

  
  void deleteRecipeIngredients(int recipeId) {
    _currentIngredients
        .removeWhere((ingredient) => ingredient.recipeId == recipeId);
    notifyListeners();
  }

  /// 一時的なものなので、初期化やクローズ時に
  /// 特に何もしない
  
  Future init() {
    return Future.value(null);
  }

  
  void close() {}
}

Mock活用:2種類のjsonファイルからランダムに読み出す

lib/mock_service/mock_service.dart

import 'dart:convert';
import 'dart:math';

import 'package:http/http.dart' as http;
import 'package:chopper/chopper.dart';
import 'package:flutter/services.dart' show rootBundle;
import '../network/model_response.dart';
import '../network/recipe_model.dart';

/// コードの変更を試みるたびに、
/// 実際のWebサーバーにリクエストを送信するのは良い考えではありません
/// 実際のAPIを模倣する特定の応答を返すモックサービスを構築
class MockService {
  /// 2種類のJSONファイルからロードされた結果を保存
  late APIRecipeQuery _currentRecipes1;
  late APIRecipeQuery _currentRecipes2;

  /// 2種類を用意し、ランダムで1つを返す
  Random nextRecipe = Random();

  /// プロバイダの設定時に、「create: (_) => MockService()..create(),」
  /// と呼ぶ
  void create() {
    loadRecipes();
  }

  void loadRecipes() async {
    var jsonString = await rootBundle.loadString('assets/recipes1.json');
    _currentRecipes1 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
    jsonString = await rootBundle.loadString('assets/recipes2.json');
    _currentRecipes2 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
  }

  /// Chopperをインポートして、Responseのインスタンスを作成します
  /// 以下のMockを作成したい
  ///   @Get(path: 'search')
  ///   Future<Response<Result<APIRecipeQuery>>> queryRecipes(
  ///       @Query('q') String query, @Query('from') int from, @Query('to') int to);
  Future<Response<Result<APIRecipeQuery>>> queryRecipes(
      String query, int from, int to) {
    /// queryRecipesが呼び出される際にランダムに2種類の内から選択
    switch (nextRecipe.nextInt(2)) {
      case 0:

        /// Response<Result<APIRecipeQuery>>として、
        return Future.value(
          Response(
            /// Header:ステータス200の正常なレスポンスとしてのダミー
            http.Response('Dummy', 200, request: null),

            /// Body:Result<APIRecipeQuery>
            Success<APIRecipeQuery>(_currentRecipes1),
          ),
        );
      case 1:
        return Future.value(Response(http.Response('Dummy', 200, request: null),
            Success<APIRecipeQuery>(_currentRecipes2)));
      default:
        return Future.value(Response(http.Response('Dummy', 200, request: null),
            Success<APIRecipeQuery>(_currentRecipes1)));
    }
  }
}

main.dart抜粋

    return MultiProvider(
      providers: [

        Provider(
          create: (_) => MockService()..create(),
          lazy: false,
        ),
      ],

abstractを利用した継承(extends):Streamを活用するインターフェイスと管理するクラス

lib/data/repository.dart

import 'models/models.dart';

/// Stream
/// データに加えてエラー送信可能
/// 必要に応じて、ストリームを停止することもできます
///
/// 2つの異なる場所でストリームを使用
///  →MyRecipesListと、ShoppingListで利用
abstract class Repository {
  /// 元々Futureのinitと、close以外にFutureか、Streamを追加
  /// データベースまたはネットワークからのデータを処理するために
  /// 非同期で機能するメソッドを使用できます
  /// ※実際にFutureでなくとも、動作しているように見える
  Future<List<Recipe>> findAllRecipes();

  /// Streamの関数を2つ追加
  ///  →レシピと食材用
  Stream<List<Recipe>> watchAllRecipes();

  Stream<List<Ingredient>> watchAllIngredients();

  /// インターフェイスとして置いているので、
  /// 実際のUI上で利用していない関数もあり
  Future<Recipe> findRecipeById(int id);

  Future<List<Ingredient>> findAllIngredients();

  Future<List<Ingredient>> findRecipeIngredients(int recipeId);

  Future<int> insertRecipe(Recipe recipe);

  Future<List<int>> insertIngredients(List<Ingredient> ingredients);

  Future<void> deleteRecipe(Recipe recipe);

  Future<void> deleteIngredient(Ingredient ingredient);

  Future<void> deleteIngredients(List<Ingredient> ingredients);

  Future<void> deleteRecipeIngredients(int recipeId);

  Future init();
  void close();
}

lib/data/memory_repository.dart

import 'dart:async';
import 'dart:core';

import 'models/models.dart';
import 'repository.dart';

/// 「with ChangeNotifier」を削除
/// ChangeNotifierProviderからProvider利用へ
/// (Stream利用としたため、追加・削除時にnotifyListeners()不要)
class MemoryRepository extends Repository {
  final List<Recipe> _currentRecipes = <Recipe>[];
  final List<Ingredient> _currentIngredients = <Ingredient>[];

  /// Stream定義
  /// ストリームが最初に要求されたときにキャプチャされます。
  /// これにより、呼び出しごとに新しいストリームが作成されなくなります。
  Stream<List<Recipe>>? _recipeStream;
  Stream<List<Ingredient>>? _ingredientStream;

  /// 【StreamControllerとStreamSink】
  /// シンクはデータの宛先:
  ///  ストリームにデータを追加する場合は、シンクに追加
  ///  StreamControllerはシンクを所有
  ///  シンク上のデータをリッスンし、そのデータをストリームリスナーに送信
  /// MemoryRepositoryでは、レシピと食材のStreamと、StreamControllerを定義し、
  /// それらを用いて、シンクに追加コードを記載
  /// (listenは別の受けて側で記載 = listenの便利な記載方法がFlutterのStreamBuilder)
  /// StreamController定義
  final StreamController _recipeStreamController =
      StreamController<List<Recipe>>();
  final StreamController _ingredientStreamController =
      StreamController<List<Ingredient>>();

  /// 新しいStreamを返す関数を二つ追加
  
  Stream<List<Recipe>> watchAllRecipes() {
    /// すでにStreamがあれば、既存を返却
    if (_recipeStream == null) {
      /// StreamControllerを利用して、シンク上のデータをリッスン
      _recipeStream = _recipeStreamController.stream as Stream<List<Recipe>>;
    }
    return _recipeStream!;
  }

  
  Stream<List<Ingredient>> watchAllIngredients() {
    /// すでにStreamがあれば、既存を返却
    if (_ingredientStream == null) {
      /// StreamControllerを利用して、シンク上のデータをリッスン
      _ingredientStream =
          _ingredientStreamController.stream as Stream<List<Ingredient>>;
    }
    return _ingredientStream!;
  }

  /// 既存関数で、RepositoryクラスのインターフェイスにFutureを付けたので追加
  
  Future<List<Recipe>> findAllRecipes() {
    return Future.value(_currentRecipes);
  }

  
  Future<Recipe> findRecipeById(int id) {
    return Future.value(
        _currentRecipes.firstWhere((recipe) => recipe.id == id));
  }

  
  Future<List<Ingredient>> findAllIngredients() {
    return Future.value(_currentIngredients);
  }

  
  Future<List<Ingredient>> findRecipeIngredients(int recipeId) {
    final recipe =
        _currentRecipes.firstWhere((recipe) => recipe.id == recipeId);
    final recipeIngredients = _currentIngredients
        .where((ingredient) => ingredient.recipeId == recipe.id)
        .toList();
    return Future.value(recipeIngredients);
  }

  
  Future<int> insertRecipe(Recipe recipe) {
    _currentRecipes.add(recipe);

    /// データの宛先であるシンクに追加
    _recipeStreamController.sink.add(_currentRecipes);
    if (recipe.ingredients != null) {
      insertIngredients(recipe.ingredients!);
    }
    return Future.value(0);
  }

  
  Future<List<int>> insertIngredients(List<Ingredient> ingredients) {
    if (ingredients.length != 0) {
      _currentIngredients.addAll(ingredients);

      /// データの宛先であるシンクに追加
      _ingredientStreamController.sink.add(_currentIngredients);
    }
    return Future.value(<int>[]);
  }

  
  Future<void> deleteRecipe(Recipe recipe) {
    _currentRecipes.remove(recipe);

    /// データの宛先であるシンクに追加(削除の結果)
    /// リスト自体をaddする
    ///   →ストリームが単一の値ではなくリストを期待しているためです。
    ///   →このようにすることで、前のリストを更新されたリストに置き換えます
    _recipeStreamController.sink.add(_currentRecipes);
    if (recipe.id != null) {
      deleteRecipeIngredients(recipe.id!);
    }
    return Future.value();
  }

  
  Future<void> deleteIngredient(Ingredient ingredient) {
    _currentIngredients.remove(ingredient);

    /// データの宛先であるシンクに追加(削除の結果)
    _ingredientStreamController.sink.add(_currentIngredients);
    return Future.value();
  }

  
  Future<void> deleteIngredients(List<Ingredient> ingredients) {
    _currentIngredients
        .removeWhere((ingredient) => ingredients.contains(ingredient));

    /// データの宛先であるシンクに追加(削除の結果)
    _ingredientStreamController.sink.add(_currentIngredients);
    return Future.value();
  }

  
  Future<void> deleteRecipeIngredients(int recipeId) {
    _currentIngredients
        .removeWhere((ingredient) => ingredient.recipeId == recipeId);

    /// データの宛先であるシンクに追加(削除の結果)
    _ingredientStreamController.sink.add(_currentIngredients);
    return Future.value();
  }

  
  Future init() {
    /// 「return Future.value(null);」からnullを削除
    return Future.value();
  }

  
  void close() {
    /// Stream終了後に閉じる
    _recipeStreamController.close();
    _ingredientStreamController.close();
  }
}

lib/main.dart抜粋

    return MultiProvider(
      providers: [
        /// ChangeNotifierProviderからProviderへ
        /// (Stream利用としたため、追加・削除時にnotifyListeners()不要)
        Provider<Repository>(
          lazy: false,
          create: (_) => MemoryRepository(),
        ),

      ],

Interface(implements)を利用した本番APIとMockと使い分け

lib/network/service_interface.dart

import 'package:chopper/chopper.dart';

import 'model_response.dart';
import 'recipe_model.dart';

/// インターフェイスを作成することで、
/// 実際のレシピ検索:MemoryRepository
/// Mockのレシピ検索:RecipeService
/// の共存・切り替えが簡単になる
abstract class ServiceInterface {
  Future<Response<Result<APIRecipeQuery>>> queryRecipes(
      String query, int from, int to);
}

lib/mock_service/mock_service.dart

import 'dart:convert';
import 'dart:math';

import 'package:chopper/chopper.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;

import '../network/model_response.dart';
import '../network/recipe_model.dart';
import '../network/service_interface.dart';

/// 「implements ServiceInterface」で検索関数をオーバーライド
class MockService implements ServiceInterface {
  late APIRecipeQuery _currentRecipes1;
  late APIRecipeQuery _currentRecipes2;
  Random nextRecipe = Random();

  void create() {
    loadRecipes();
  }

  void loadRecipes() async {
    var jsonString = await rootBundle.loadString('assets/recipes1.json');
    _currentRecipes1 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
    jsonString = await rootBundle.loadString('assets/recipes2.json');
    _currentRecipes2 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
  }

  /// オーバーライド
  
  Future<Response<Result<APIRecipeQuery>>> queryRecipes(
      String query, int from, int to) {
    switch (nextRecipe.nextInt(2)) {
      case 0:
        return Future.value(Response(http.Response('Dummy', 200, request: null),
            Success<APIRecipeQuery>(_currentRecipes1)));
      case 1:
        return Future.value(Response(http.Response('Dummy', 200, request: null),
            Success<APIRecipeQuery>(_currentRecipes2)));
      default:
        return Future.value(Response(http.Response('Dummy', 200, request: null),
            Success<APIRecipeQuery>(_currentRecipes1)));
    }
  }
}

lib/network/recipe_service.dart

import 'package:chopper/chopper.dart';

import 'model_converter.dart';
import 'model_response.dart';
import 'recipe_model.dart';
import 'service_interface.dart';

part 'recipe_service.chopper.dart';

/// 自分でアカウント作成し、取得
// https://www.raywenderlich.com/books/flutter-apprentice/v2.0/chapters/11-networking-in-flutter
const String apiKey = '<Your Key Here>';
const String apiId = '<Your Id here>';
const String apiUrl = 'https://api.edamam.com';

/// 「implements ServiceInterface」で検索関数をオーバーライド
()
abstract class RecipeService extends ChopperService
    implements ServiceInterface {
  /// オーバーライド
  
  (path: 'search')
  Future<Response<Result<APIRecipeQuery>>> queryRecipes(
      ('q') String query, ('from') int from, ('to') int to);

  static RecipeService create() {
    final client = ChopperClient(
      baseUrl: apiUrl,
      interceptors: [_addQuery, HttpLoggingInterceptor()],
      converter: ModelConverter(),
      errorConverter: const JsonConverter(),
      services: [
        _$RecipeService(),
      ],
    );
    return _$RecipeService(client);
  }
}

Request _addQuery(Request req) {
  final params = Map<String, dynamic>.from(req.parameters);
  params['app_id'] = apiId;
  params['app_key'] = apiKey;

  return req.copyWith(parameters: params);
}

lib/main.dart抜粋

    return MultiProvider(
      providers: [

        /// インターフェイスを作成することで、
        /// 実際の検索と、Mock検索の切り替えが簡単に!
        /// 本番
        Provider<ServiceInterface>(
          create: (_) => RecipeService.create(),
          lazy: false,
        ),

        /// Mock(※本番を利用の際は、コメントアウト)
        // Provider<ServiceInterface>(
        //   create: (_) => MockService()..create(),
        //   lazy: false,
        // ),
      ],

abstractを利用した継承(extends):DatabaseにsqfliteかMoorか選択

lib/data/repository.dart

import 'models/models.dart';

/// 前章までは、MemoryRepositoryがのみがextendsし、
/// 一時的な保存のみに利用していた。
/// 今回は、sqflite利用ではSqliteRepositoryが、
/// Moor利用ではMoorRepositoryがextendsし、
/// 永続保存を使い分ける
///
/// レシピと、食材情報を管理するRepositoryクラスを作成し、
/// そのRepositoryがDatabaseへの追加や削除も実施する
abstract class Repository {
  Future<List<Recipe>> findAllRecipes();

  Stream<List<Recipe>> watchAllRecipes();

  Stream<List<Ingredient>> watchAllIngredients();

  Future<Recipe> findRecipeById(int id);

  Future<List<Ingredient>> findAllIngredients();

  Future<List<Ingredient>> findRecipeIngredients(int recipeId);

  Future<int> insertRecipe(Recipe recipe);

  Future<List<int>> insertIngredients(List<Ingredient> ingredients);

  Future<void> deleteRecipe(Recipe recipe);

  Future<void> deleteIngredient(Ingredient ingredient);

  Future<void> deleteIngredients(List<Ingredient> ingredients);

  Future<void> deleteRecipeIngredients(int recipeId);

  Future init();
  void close();
}

lib/data/sqlite/sqlite_repository.dart【データベース:sqflite関連】

import 'dart:async';

import '../models/models.dart';
import '../repository.dart';
import 'database_helper.dart';

/// 【sqfliteを使用する場合】
/// データベースのすべてのテーブルを手動で作成し、
/// SQLステートメントを手動でまとめる必要があります。
class SqliteRepository extends Repository {
  /// 別途作成のDatabaseHelperのインスタンスを作成することにより、
  /// まず、データベースを作成
  /// このファイルでdbHelperの関数を呼び出す書き方にすることで、
  /// データベース利用をしていない一時保存の際と大差がない見た目となる
  final dbHelper = DatabaseHelper.instance;

  
  Future<List<Recipe>> findAllRecipes() {
    return dbHelper.findAllRecipes();
  }

  
  Stream<List<Recipe>> watchAllRecipes() {
    return dbHelper.watchAllRecipes();
  }

  
  Stream<List<Ingredient>> watchAllIngredients() {
    return dbHelper.watchAllIngredients();
  }

  
  Future<Recipe> findRecipeById(int id) {
    return dbHelper.findRecipeById(id);
  }

  
  Future<List<Ingredient>> findAllIngredients() {
    return dbHelper.findAllIngredients();
  }

  
  Future<List<Ingredient>> findRecipeIngredients(int id) {
    return dbHelper.findRecipeIngredients(id);
  }

  /// 挿入メソッドはもう少し多くのことを行います
  /// recipeId各材料にを設定するには、
  /// 最初にレシピをデータベースに挿入し、
  /// 挿入呼び出しから返されたレシピIDを取得する必要があります
  ///  =ここで初めてrecipeのidを設定しているので、
  ///   ブックマークの削除機能が機能する
  
  Future<int> insertRecipe(Recipe recipe) {
    return Future(
      () async {
        final id = await dbHelper.insertRecipe(recipe);
        recipe.id = id;
        if (recipe.ingredients != null) {
          recipe.ingredients!.forEach((ingredient) {
            ingredient.recipeId = id;
          });

          /// レシピの食材を追加
          insertIngredients(recipe.ingredients!);
        }
        return id;
      },
    );
  }

  
  Future<List<int>> insertIngredients(List<Ingredient> ingredients) {
    return Future(
      () async {
        if (ingredients.length != 0) {
          final ingredientIds = <int>[];

          /// forEachのawaitの使い方
          await Future.forEach(ingredients, (Ingredient ingredient) async {
            final futureId = await dbHelper.insertIngredient(ingredient);

            /// 食材にもidを一つずつ付ける
            ingredient.id = futureId;
            ingredientIds.add(futureId);
          });
          return Future.value(ingredientIds);
        } else {
          return Future.value(<int>[]);
        }
      },
    );
  }

  
  Future<void> deleteRecipe(Recipe recipe) {
    dbHelper.deleteRecipe(recipe);
    if (recipe.id != null) {
      /// レシピの食材を削除
      deleteRecipeIngredients(recipe.id!);
    }
    return Future.value();
  }

  
  Future<void> deleteIngredient(Ingredient ingredient) {
    dbHelper.deleteIngredient(ingredient);
    return Future.value();
  }

  
  Future<void> deleteIngredients(List<Ingredient> ingredients) {
    dbHelper.deleteIngredients(ingredients);
    return Future.value();
  }

  
  Future<void> deleteRecipeIngredients(int recipeId) {
    dbHelper.deleteRecipeIngredients(recipeId);
    return Future.value();
  }

  
  Future init() async {
    await dbHelper.database;
    return Future.value();
  }

  
  void close() {
    dbHelper.close();
  }
}

lib/data/sqlite/database_helper.dart【データベース:sqflite関連】

import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqlbrite/sqlbrite.dart';
import 'package:synchronized/synchronized.dart';

import '../models/models.dart';

/// このクラスは、すべてのSQLiteデータベース操作を処理
class DatabaseHelper {
  static const _databaseName = 'MyRecipes.db';
  static const _databaseVersion = 1;

  static const recipeTable = 'Recipe';
  static const ingredientTable = 'Ingredient';
  static const recipeId = 'recipeId';
  static const ingredientId = 'ingredientId';

  /// sqlbriteライブラリは、sqfliteのリアクティブストリームラッパー
  /// データベースに変更があったときにイベントを受信できるように、Streamを設定
  /// BriteDatabaseクラスのインスタンスに、queryやinsert関数が存在
  static late BriteDatabase _streamDatabase;

  /// シングルトンとは シングルトンクラスは、
  /// あるクラスのインスタンスが2つ以上生成されないことを保証する
  /// デザイン・パターンの1つ
  /// ※データベース管理のインスタンスは一つのみが正しい
  // make this a singleton class
  DatabaseHelper._privateConstructor();

  /// 「 instance.streamDatabase」と関数内でシングルトン利用
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();

  /// 同時アクセスを防止(synchronizedパッケージ)
  static var lock = Lock();

  /// データベースインスタンス
  // only have a single app-wide reference to the database
  static Database? _database;

  /// db,versionを引数に持つ
  /// REALはdouble値
  /// idをPRIMARY KEYとする
  // SQL code to create the database table
  Future _onCreate(Database db, int version) async {
    /// レシピテーブル
    await db.execute('''
        CREATE TABLE $recipeTable (
          $recipeId INTEGER PRIMARY KEY,
          label TEXT,
          image TEXT,
          url TEXT,
          calories REAL,
          totalWeight REAL,
          totalTime REAL
        )
        ''');

    /// 食材テーブル
    // 3
    await db.execute('''
        CREATE TABLE $ingredientTable (
          $ingredientId INTEGER PRIMARY KEY,
          $recipeId INTEGER,
          name TEXT,
          weight REAL
        )
        ''');
  }

  /// データベースを利用する前に、初期化する用
  // this opens the database (and creates it if it doesn't exist)
  Future<Database> _initDatabase() async {
    /// データベースを保存するアプリドキュメントのディレクトリ名を取得
    /// (path_providerパッケージ)
    final documentsDirectory = await getApplicationDocumentsDirectory();

    /// データベース名をディレクトリパスに追加して、データベースへのパスを作成
    final path = join(documentsDirectory.path, _databaseName);

    /// デバッグをオンにします。
    /// アプリをストアにデプロイする準備ができたら、
    /// これをオフにすることを忘れないでください。
    Sqflite.setDebugModeOn(true);

    /// sqfliteの関数で、データベースファイルを作成:パスと、バージョン番号、作成関数
    return openDatabase(path, version: _databaseVersion, onCreate: _onCreate);
  }

  /// _databaseはプライベートなので、データベースを初期化するgetterを作成
  Future<Database> get database async {
    if (_database != null) return _database!;
    // Use this object to prevent concurrent access to data
    await lock.synchronized(
      () async {
        // lazily instantiate the db the first time it is accessed
        if (_database == null) {
          _database = await _initDatabase();

          /// 初期化後に、BriteDatabaseデータベースをラップして、インスタンスを作成
          /// (Streamとして変更を随時受け付ける用)
          _streamDatabase = BriteDatabase(_database!);
        }
      },
    );
    return _database!;
  }

  /// _streamDatabaseのgetter
  Future<BriteDatabase> get streamDatabase async {
    /// データベースを取得(存在しなければ作成)
    await database;
    return _streamDatabase;
  }

  /// SQLデータをJSONに、またはその逆に変換するためのヘルパーメソッドをいくつか追加
  /// RecipeとIngredientクラスにも個別のjson変換を追加
  ///
  /// レシピテーブルから受けとったデータをRecipeクラスのリストに変換
  List<Recipe> parseRecipes(List<Map<String, dynamic>> recipeList) {
    final recipes = <Recipe>[];
    recipeList.forEach(
      (recipeMap) {
        final recipe = Recipe.fromJson(recipeMap);
        recipes.add(recipe);
      },
    );
    return recipes;
  }

  /// 食材テーブルから受けとったデータをIngredientクラスのリストに変換
  List<Ingredient> parseIngredients(List<Map<String, dynamic>> ingredientList) {
    final ingredients = <Ingredient>[];
    ingredientList.forEach(
      (ingredientMap) {
        final ingredient = Ingredient.fromJson(ingredientMap);
        ingredients.add(ingredient);
      },
    );
    return ingredients;
  }

  /// 以下からはこれまで通りRepositoryクラスに必要な関数を実装
  /// (データベース利用版)
  ///
  Future<List<Recipe>> findAllRecipes() async {
    final db = await instance.streamDatabase;

    /// すべてのレシピをMap情報として取得
    final recipeList = await db.query(recipeTable);
    final recipes = parseRecipes(recipeList);
    return recipes;
  }

  /// yield*を利用するため、async*
  Stream<List<Recipe>> watchAllRecipes() async* {
    final db = await instance.streamDatabase;

    /// 「yield*」を付けることで、Stream<List<Recipe>>の返す
    /// ※無いとエラー:The type 'Stream<Stream<List<Recipe>>>' implied by the 'yield' expression must be assignable to 'Stream<List<Recipe>>'.
    /// yieldは式を計算し、結果の値を渡す(複数回実行できるreturnのようなもの)
    /// yield*を用いて、Streamへの値渡しを一時停止することが可能
    /// (Streamの中でStream別関数を呼ぶ )
    ///
    /// createQueryでレシピテーブルを取得することで、便利なmapToList関数利用
    /// 行ごとに、その行のレシピMapをRecipeのリストに変換
    yield* db.createQuery(recipeTable).mapToList((row) => Recipe.fromJson(row));
  }

  /// ☝と同様
  Stream<List<Ingredient>> watchAllIngredients() async* {
    final db = await instance.streamDatabase;
    yield* db
        .createQuery(ingredientTable)
        .mapToList((row) => Ingredient.fromJson(row));
  }

  Future<Recipe> findRecipeById(int id) async {
    final db = await instance.streamDatabase;

    /// 特定idのレシピをMap情報として取得
    final recipeList = await db.query(recipeTable, where: 'id = $id');

    /// 複数ある場合は最初のものを選択
    final recipes = parseRecipes(recipeList);
    return recipes.first;
  }

  Future<List<Ingredient>> findAllIngredients() async {
    final db = await instance.streamDatabase;

    /// 全ての食材をMap情報として取得
    final ingredientList = await db.query(ingredientTable);
    final ingredients = parseIngredients(ingredientList);
    return ingredients;
  }

  Future<List<Ingredient>> findRecipeIngredients(int recipeId) async {
    final db = await instance.streamDatabase;

    /// 特定recipeIdの食材をMap情報として取得
    final ingredientList =
        await db.query(ingredientTable, where: 'recipeId = $recipeId');
    final ingredients = parseIngredients(ingredientList);
    return ingredients;
  }

  /// レシピと食材共通のinsert
  /// テーブル名とJSONマップを使用して挿入
  Future<int> insert(String table, Map<String, dynamic> row) async {
    final db = await instance.streamDatabase;

    /// 最終行に追加し、その行数が返される(追加位置が分かる:1~)
    /// ※ブックマークした中間のレシピを削除した後に、再度追加するとバグりそう
    return db.insert(table, row);
  }

  Future<int> insertRecipe(Recipe recipe) {
    return insert(recipeTable, recipe.toJson());
  }

  Future<int> insertIngredient(Ingredient ingredient) {
    return insert(ingredientTable, ingredient.toJson());
  }

  /// レシピと食材共通の_delete
  /// テーブル名とcolumnId(recipeId or ingredientId), idが必要
  Future<int> _delete(String table, String columnId, int id) async {
    final db = await instance.streamDatabase;

    /// レシピの場合は、recipeIdのrecipe.idのものを削除
    /// whereArgsには、削除するデータのwhere条件を「?」を使って
    /// パラメータで指定した場合のパラメータ値をString配列で指定します。
    /// WHERE条件に「?」パラメータが無い場合は、nullを指定します。
    /// 以下でも動作したが、可読性優先
    /// return db.delete(table, where: '$columnId = $id');
    return db.delete(table, where: '$columnId = ?', whereArgs: [id]);
  }

  Future<int> deleteRecipe(Recipe recipe) async {
    if (recipe.id != null) {
      return _delete(recipeTable, recipeId, recipe.id!);
    } else {
      return Future.value(-1);
    }
  }

  Future<int> deleteIngredient(Ingredient ingredient) async {
    if (ingredient.id != null) {
      return _delete(ingredientTable, ingredientId, ingredient.id!);
    } else {
      return Future.value(-1);
    }
  }

  Future<void> deleteIngredients(List<Ingredient> ingredients) {
    ingredients.forEach((ingredient) {
      if (ingredient.id != null) {
        _delete(ingredientTable, ingredientId, ingredient.id!);
      }
    });
    return Future.value();
  }

  /// レシピに紐づいた食材は複数あり
  Future<int> deleteRecipeIngredients(int id) async {
    final db = await instance.streamDatabase;
    return db.delete(ingredientTable, where: '$recipeId = ?', whereArgs: [id]);
  }

  void close() {
    _streamDatabase.close();
  }
}

lib/data/moor/moor_repository.dart【データベース:Moor関連】

import 'dart:async';

import '../models/models.dart';
import '../repository.dart';
import 'moor_db.dart';

/// 'moor_db.dart'と、'moor_db.g.dart'を作成した後に、
/// 表面のリポジトリクラスである本クラスを作成する
///
class MoorRepository extends Repository {
  late RecipeDatabase recipeDatabase;

  /// Daoはプライベート
  late RecipeDao _recipeDao;
  late IngredientDao _ingredientDao;
  Stream<List<Ingredient>>? ingredientStream;
  Stream<List<Recipe>>? recipeStream;

  
  Future<List<Recipe>> findAllRecipes() {
    /// Daoが持っているデータベース操作関数を実行
    /// findAllRecipes()はFuture<List<MoorRecipeData>>を返すので、
    /// thenを利用し、List<MoorRecipeData>→List<Recipe>
    /// の変換を中で実施
    return _recipeDao.findAllRecipes().then<List<Recipe>>(
      (List<MoorRecipeData> moorRecipes) {
        final recipes = <Recipe>[];
        moorRecipes.forEach(
          (moorRecipe) async {
            /// Moorレシピをモデルレシピに変換
            final recipe = moorRecipeToRecipe(moorRecipe);
            if (recipe.id != null) {
              /// レシピが持つ食材をここで、Recipeのインスタンスに格納
              recipe.ingredients = await findRecipeIngredients(recipe.id!);
            }
            recipes.add(recipe);
          },
        );
        return recipes;
      },
    );
  }

  
  Stream<List<Recipe>> watchAllRecipes() {
    if (recipeStream == null) {
      recipeStream = _recipeDao.watchAllRecipes();
    }
    return recipeStream!;
  }

  
  Stream<List<Ingredient>> watchAllIngredients() {
    if (ingredientStream == null) {
      final stream = _ingredientDao.watchAllIngredients();
      ingredientStream = stream.map(
        (moorIngredients) {
          final ingredients = <Ingredient>[];
          moorIngredients.forEach(
            (moorIngredient) {
              /// 各データベースクラスをモデルクラスに変換が必要
              ingredients.add(moorIngredientToIngredient(moorIngredient));
            },
          );
          return ingredients;
        },
      );

      /// mapを利用するなら以下のみ
      // ingredientStream = _ingredientDao.watchAllIngredientsTest();
    }
    return ingredientStream!;
  }

  
  Future<Recipe> findRecipeById(int id) {
    /// 各データベースクラスをモデルクラスに変換が必要
    return _recipeDao
        .findRecipeById(id)
        .then((listOfRecipes) => moorRecipeToRecipe(listOfRecipes.first));
  }

  
  Future<List<Ingredient>> findAllIngredients() {
    return _ingredientDao.findAllIngredients().then<List<Ingredient>>(
      (List<MoorIngredientData> moorIngredients) {
        final ingredients = <Ingredient>[];
        moorIngredients.forEach(
          (ingredient) {
            /// 各データベースクラスをモデルクラスに変換が必要
            ingredients.add(moorIngredientToIngredient(ingredient));
          },
        );
        return ingredients;
      },
    );
  }

  
  Future<List<Ingredient>> findRecipeIngredients(int recipeId) {
    return _ingredientDao.findRecipeIngredients(recipeId).then(
      (listOfIngredients) {
        final ingredients = <Ingredient>[];
        listOfIngredients.forEach(
          (ingredient) {
            /// 各データベースクラスをモデルクラスに変換が必要
            ingredients.add(moorIngredientToIngredient(ingredient));
          },
        );
        return ingredients;
      },
    );
  }

  /// レシピを挿入するには、最初にレシピ自体を挿入し、次にそのすべての材料を挿入
  
  Future<int> insertRecipe(Recipe recipe) {
    return Future(
      () async {
        /// モデルクラスを各データベースクラスに変換が必要
        final id =
            await _recipeDao.insertRecipe(recipeToInsertableMoorRecipe(recipe));
        if (recipe.ingredients != null) {
          recipe.ingredients!.forEach(
            (ingredient) {
              ingredient.recipeId = id;
            },
          );
          insertIngredients(recipe.ingredients!);
        }
        return id;
      },
    );
  }

  /// レシピ追加時に、レシピの持つ複数食材追加関数
  
  Future<List<int>> insertIngredients(List<Ingredient> ingredients) {
    return Future(
      () {
        if (ingredients.length == 0) {
          return <int>[];
        }
        final resultIds = <int>[];
        ingredients.forEach(
          (ingredient) {
            /// モデルクラスを各データベースクラスに変換が必要
            final moorIngredient =
                ingredientToInsertableMoorIngredient(ingredient);
            _ingredientDao
                .insertIngredient(moorIngredient)
                .then((int id) => resultIds.add(id));
          },
        );
        return resultIds;
      },
    );
  }

  
  Future<void> deleteRecipe(Recipe recipe) {
    if (recipe.id != null) {
      _recipeDao.deleteRecipe(recipe.id!);
    }
    return Future.value();
  }

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

  
  Future<void> deleteIngredients(List<Ingredient> ingredients) {
    ingredients.forEach(
      (ingredient) {
        if (ingredient.id != null) {
          _ingredientDao.deleteIngredient(ingredient.id!);
        }
      },
    );
    return Future.value();
  }

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

  
  Future init() async {
    recipeDatabase = RecipeDatabase();

    /// データベース取得後に、DAOのインスタンスを取得
    _recipeDao = recipeDatabase.recipeDao;
    _ingredientDao = recipeDatabase.ingredientDao;
  }

  
  void close() {
    recipeDatabase.close();
  }
}

lib/data/moor/moor_db.dart【データベース:Moor関連】

import 'package:moor_flutter/moor_flutter.dart';

import '../models/models.dart';

part 'moor_db.g.dart';

/// 以下のコマンド実行で、「moor_db.g.dart」作成
/// # flutter pub run build_runner build --delete-conflicting-outputs
/// --delete-conflicting-outputs以前に生成されたファイルを削除してから、
/// それらを再構築します。
///
/// 【DAO】
/// DAOは、データベースからのデータへのアクセスを担当するクラス
/// ビジネスロジックコード(たとえば、レシピの成分をフェッチするコード)を永続層
/// (この場合はSQLite)の詳細から分離するために使用
/// DAOは、クラス、インターフェイス、または抽象クラスにすることができます
/// この章では、クラスを使用してDAOを実装
/// 【sqfliteとの違い】
/// テーブル作成も手動的よりは、分かりやすい
/// レシピテーブル
///     await db.execute('''
///         CREATE TABLE $recipeTable (
///           $recipeId INTEGER PRIMARY KEY,
///           label TEXT,
///           image TEXT,
///           url TEXT,
///           calories REAL,
///           totalWeight REAL,
///           totalTime REAL
///         )
///         ''');
///
/// Table:レシピ
class MoorRecipe extends Table {
  /// autoIncrement()自動的にIDを作成
  IntColumn get id => integer().autoIncrement()();

  TextColumn get label => text()();

  TextColumn get image => text()();

  TextColumn get url => text()();

  /// Real→double
  RealColumn get calories => real()();

  RealColumn get totalWeight => real()();

  RealColumn get totalTime => real()();
}

/// Table:食材
class MoorIngredient extends Table {
  /// autoIncrement()自動的にIDを作成
  IntColumn get id => integer().autoIncrement()();

  IntColumn get recipeId => integer()();

  TextColumn get name => text()();

  RealColumn get weight => real()();
}

/// @UseMoor
/// 使用するテーブルとデータアクセスオブジェクト(DAO)を指定
(tables: [MoorRecipe, MoorIngredient], daos: [RecipeDao, IngredientDao])

/// _$RecipeDatabaseは、自動作成する「part 'moor_db.g.dart';」
/// に含まれる
class RecipeDatabase extends _$RecipeDatabase {
  RecipeDatabase()
      : super(FlutterQueryExecutor.inDatabaseFolder(
            path: 'recipes.sqlite', logStatements: true));

  
  int get schemaVersion => 1;
}

/// @UseDao
/// MoorRecipeテーブルのDAOクラスであることを指定
(tables: [MoorRecipe])
class RecipeDao extends DatabaseAccessor<RecipeDatabase> with _$RecipeDaoMixin {
  final RecipeDatabase db;

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

  /// 簡単なselectクエリを使用して、すべてのレシピを検索
  /// 「MoorRecipeData」というものとする
  Future<List<MoorRecipeData>> findAllRecipes() => select(moorRecipe).get();

  /// select・watch等を合わせて、すべてのレシピのStreamを返す
  /// selectでテーブル指定、
  /// watchでStream作成、
  /// 行ごとのデータ(List<MoorRecipeData>)を取得、
  /// 個々のMoorRecipeDataをRecipeに変換し、
  /// 重複したレシピ出なければ、
  /// 空の材料リストを作成して、レシピリストに追加
  ///
  /// mapを利用し、上手く<List<Recipe>としているので、
  /// 使うときに楽
  Stream<List<Recipe>> watchAllRecipes() {
    return select(moorRecipe).watch().map(
      (rows) {
        final recipes = <Recipe>[];
        rows.forEach(
          (row) {
            final recipe = moorRecipeToRecipe(row);
            if (!recipes.contains(recipe)) {
              recipe.ingredients = <Ingredient>[];
              recipes.add(recipe);
            }
          },
        );
        return recipes;
      },
    );
  }

  /// whereIDでレシピをフェッチするために使用するより複雑なクエリを定義
  Future<List<MoorRecipeData>> findRecipeById(int id) =>
      (select(moorRecipe)..where((tbl) => tbl.id.equals(id))).get();

  /// into()およびinsert()を使用して、新しいレシピを追加
  /// テーブルを指定してクラスに渡すだけ
  /// Insertableは、Moorが必要とするインターフェイスです。
  /// partファイルを生成すると、MoorRecipeDataのインターフェイスを
  /// 実装する新しいクラスが生まれる
  ///  - class MoorRecipeData extends DataClass implements Insertable<MoorRecipeData> {}
  Future<int> insertRecipe(Insertable<MoorRecipeData> recipe) =>
      into(moorRecipe).insert(recipe);

  /// delete()およびwhere()を使用して、特定のレシピを削除
  /// goで削除した行のみ取得
  Future deleteRecipe(int id) => Future.value(
      (delete(moorRecipe)..where((tbl) => tbl.id.equals(id))).go());
}

/// @UseDao
/// MoorIngredientテーブルのDAOクラスであることを指定
(tables: [MoorIngredient])
class IngredientDao extends DatabaseAccessor<RecipeDatabase>
    with _$IngredientDaoMixin {
  final RecipeDatabase db;

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

  /// 簡単なselectクエリを使用して、すべての食材を検索
  /// 「MoorIngredientData」というものとする
  Future<List<MoorIngredientData>> findAllIngredients() =>
      select(moorIngredient).get();

  /// Streamを作成するための、watch関数
  ///
  /// mapを利用していないので、呼び出し元で、
  /// List<MoorIngredientData>→List<Ingredient>等に変換して利用
  Stream<List<MoorIngredientData>> watchAllIngredients() =>
      select(moorIngredient).watch();

  /// mapを利用するなら以下
  Stream<List<Ingredient>> watchAllIngredientsTest() =>
      select(moorIngredient).watch().map(
        (rows) {
          final ingredients = <Ingredient>[];
          rows.forEach(
            (row) {
              final ingredient = moorIngredientToIngredient(row);
              if (!ingredients.contains(ingredient)) {
                ingredients.add(ingredient);
              }
            },
          );
          return ingredients;
        },
      );

  /// レシピIDに一致するすべての材料を選択
  Future<List<MoorIngredientData>> findRecipeIngredients(int id) =>
      (select(moorIngredient)..where((tbl) => tbl.recipeId.equals(id))).get();

  /// into()とinsert()を使用して、新しい材料を追加
  Future<int> insertIngredient(Insertable<MoorIngredientData> ingredient) =>
      into(moorIngredient).insert(ingredient);

  /// 使用delete()に加えてwhere()、特定の成分を削除
  Future deleteIngredient(int id) => Future.value(
      (delete(moorIngredient)..where((tbl) => tbl.id.equals(id))).go());
}

/// ☝ここまでで、以下コマンドで作成して、データベースクラス関連コードを確認
///  - flutter pub run build_runner build --delete-conflicting-outputs
///
/// 上記のテーブルを定義したので、
/// データベースクラスを通常のモデルクラスに変換したり、
/// 元に戻したりするメソッドを作成する
// Conversion Methods
Recipe moorRecipeToRecipe(MoorRecipeData recipe) {
  return Recipe(
      id: recipe.id,
      label: recipe.label,
      image: recipe.image,
      url: recipe.url,
      calories: recipe.calories,
      totalWeight: recipe.totalWeight,
      totalTime: recipe.totalTime);
}

/// Moorデータベースに挿入できるクラスに変換
Insertable<MoorRecipeData> recipeToInsertableMoorRecipe(Recipe recipe) {
  /// Moor[クラス名]Companion.insert()関数で作成
  return MoorRecipeCompanion.insert(
      label: recipe.label ?? '',
      image: recipe.image ?? '',
      url: recipe.url ?? '',
      calories: recipe.calories ?? 0,
      totalWeight: recipe.totalWeight ?? 0,
      totalTime: recipe.totalTime ?? 0);
}

Ingredient moorIngredientToIngredient(MoorIngredientData ingredient) {
  return Ingredient(
      id: ingredient.id,
      recipeId: ingredient.recipeId,
      name: ingredient.name,
      weight: ingredient.weight);
}

/// Moorデータベースに挿入できるクラスに変換
MoorIngredientCompanion ingredientToInsertableMoorIngredient(
    Ingredient ingredient) {
  /// Moor[クラス名]Companion.insert()関数で作成
  return MoorIngredientCompanion.insert(
      recipeId: ingredient.recipeId ?? 0,
      name: ingredient.name ?? '',
      weight: ingredient.weight ?? 0);
}

lib/data/moor/moor_db.g.dart【データベース:Moor関連】※自動作成

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'moor_db.dart';

// **************************************************************************
// MoorGenerator
// **************************************************************************

// ignore_for_file: unnecessary_brace_in_string_interps, unnecessary_this
class MoorRecipeData extends DataClass implements Insertable<MoorRecipeData> {
// ・・・省略
}

// **************************************************************************
// DaoGenerator
// **************************************************************************

mixin _$RecipeDaoMixin on DatabaseAccessor<RecipeDatabase> {
  $MoorRecipeTable get moorRecipe => attachedDatabase.moorRecipe;
}
mixin _$IngredientDaoMixin on DatabaseAccessor<RecipeDatabase> {
  $MoorIngredientTable get moorIngredient => attachedDatabase.moorIngredient;
}

lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';

import 'data/repository.dart';
import 'data/sqlite/sqlite_repository.dart';
import 'mock_service/mock_service.dart';
import 'network/service_interface.dart';
import 'ui/main_screen.dart';

Future<void> main() async {
  _setupLogging();
  WidgetsFlutterBinding.ensureInitialized();

  /// DatabaseにsqfliteかMoorか選択
  /// (final repositoryと定義することで以下の1行のみの変更で対応可能)
  final repository = SqliteRepository();
  // final repository = MoorRepository();

  /// 初期化し、データベースを準備(バックグラウンドで開いておく)
  /// awaitで呼びたいのでMyAppではなく、ここで呼ぶ
  await repository.init();
  runApp(MyApp(repository: repository));
}

void _setupLogging() {
  Logger.root.level = Level.ALL;
  Logger.root.onRecord.listen(
    (rec) {
      print('${rec.level.name}: ${rec.time}: ${rec.message}');
    },
  );
}

class MyApp extends StatelessWidget {
  /// Repositoryを持つと定義し、
  /// SqliteRepository or MoorRepositoryを受け取る
  final Repository repository;

  const MyApp({Key? key, required this.repository}) : super(key: key);

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<Repository>(
          lazy: false,
          create: (_) => repository,
          dispose: (_, Repository repository) => repository.close(),
        ),
        Provider<ServiceInterface>(
          /// 本番API or Mock
          create: (_) => MockService()..create(),
          // create: (_) => RecipeService.create(),
          lazy: false,
        ),
      ],
      child: MaterialApp(
        title: 'Recipes',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          brightness: Brightness.light,
          primaryColor: Colors.white,
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: const MainScreen(),
      ),
    );
  }
}

Discussion