FlutterでのGoogle Books APIを利用した書籍検索機能の実装 1/2

# はじめに

Flutter アプリケーションで Google Books API を利用してみたので、方法についてまとめます。

この記事では、**hukusuke1007 / [flutter_app_template](https://github.com/hukusuke1007/flutter_app_template)** をベースにして開発しています。

https://github.com/hukusuke1007/flutter_app_template

Flutter + Firebase アプリのスターターキットになっており、サンプル機能がとても充実しており、今回の Google Books API を利用した書籍検索機能もテンプレートをベースにしています。

## 前提

- **hukusuke1007 / [flutter_app_template](https://github.com/hukusuke1007/flutter_app_template)** の導入（`git clone` から `flutter run` まで）が完了していること

https://qiita.com/ryamate/items/f50aa769dbc34a39fa64

## ゴール

Google Books API で書籍データを取得して、取得結果を表示させます。

<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/790c3f1d-5436-6098-90dd-9796cb6b58b3.png">

検索タブを開いたら、検索フィールドにキーワードを入力できるようにするつもりですが、とりあえず今回の記事では特定のキーワードで固定してデータ取得します。

## 目次

[1. 実装前に実施すること、確認すること](#1-実装前に実施すること確認すること)
　[1-1. Google Books API 利用のためのAPIキーの設定](#1-1-google-books-api-利用のためのapiキーの設定)
　[1-2. 使用するパッケージ](#1-2-使用するパッケージ)
　　[補足. 使用しなかったパッケージ](#補足-使用しなかったパッケージ)
　[1-3. Google Books API を Postman でテスト](#1-3-google-books-api-を-postman-でテスト)
[2. アプリでの実装](#2-アプリでの実装)
　[2-1. モデルの実装](#2-1-モデルの実装)
　[2-2. プロバイダの実装](#2-2-プロバイダの実装)
　[2-3. リポジトリの実装](#2-3-リポジトリの実装)
　[2-4. コントローラの実装](#2-4-コントローラの実装)
　[2-5. UI の実装](#2-5-ui-の実装)
　　[補足. build_runner による自動生成](#補足-build_runner-による自動生成)
[3. 動作確認](#3-動作確認)

 

# 1. 実装前に実施すること、確認すること

実装に入る前に、API キーの準備、使用するパッケージの確認などを行います。

## 1-1. Google Books API 利用のためのAPIキーの設定

Google Books API を利用する際の API キーを作成します。

https://qiita.com/ryamate/items/2a0cba391829e20009aa

## 1-2. 使用するパッケージ

以下のパッケージを使用します。

```yaml:確認：pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
    
  # Riverpod
  hooks_riverpod: 2.4.10
  riverpod_annotation: 2.3.4
  
  # Entity
  freezed_annotation: 2.4.1
  json_annotation: 4.8.1
  
  # Api
  retrofit: 4.1.0
  dio: 5.4.1

dev_dependencies:
  # Code generator
  build_runner: 2.4.8
  freezed: 2.4.7
  flutter_gen_runner: 5.4.0
  retrofit_generator: 8.1.0
  riverpod_generator: 2.3.11
```

https://pub.dev/packages/hooks_riverpod

https://pub.dev/packages/riverpod_annotation

https://pub.dev/packages/freezed

## 補足. 使用しなかったパッケージ

今回記事にしている内容の実装をしたあとに存在に気づいた、という理由で選択しなかったのですが、Books API（Google APIs）の実装に利用できる Flutter パッケージがありました。

https://pub.dev/packages/googleapis

ドキュメントを見る限りは、幅広い利用に対応していて便利そうなのですが、今回は Books API を書籍検索のみに利用するため、作り替えて切り替えるほどでもないかなと現時点は考えています。

## 1-3. Google Books API を Postman でテスト

[Postman](https://www.postman.com/)  を利用して、Google Books API のリクエストをテストしてみます。

```:リクエスト
https://www.googleapis.com/books/v1/volumes?q=ノラネコぐんだん&startIndex=0&maxResults=1&key=yourAPIKey
```

```json:レスポンス
{
    "kind": "books#volumes",
    "totalItems": 392,
    "items": [
        {
            "kind": "books#volume",
            "id": "mN2sNAEACAAJ",
            "etag": "JNEFAAbLQF0",
            "selfLink": "https://www.googleapis.com/books/v1/volumes/mN2sNAEACAAJ",
            "volumeInfo": {
                "title": "ノラネコぐんだんパンこうじょう",
                "authors": [
                    "工藤ノリコ"
                ],
                "publisher": "Hakusensha/Tsai Fong Books",
                "publishedDate": "2012-11",
                "description": "ノラネコぐんだんは、おいしそうなパンが気になり、ワンワンちゃんのパンこうじょうにしのびこみました。食いしん坊のノラネコぐんだんが見よう見まねでパンづくりに挑戦、大騒動を巻き起こします。",
                "industryIdentifiers": [
                    {
                        "type": "ISBN_10",
                        "identifier": "459276157X"
                    },
                    {
                        "type": "ISBN_13",
                        "identifier": "9784592761570"
                    }
                ],
                "readingModes": {
                    "text": false,
                    "image": false
                },
                "pageCount": 1,
                "printType": "BOOK",
                "maturityRating": "NOT_MATURE",
                "allowAnonLogging": false,
                "contentVersion": "preview-1.0.0",
                "imageLinks": {
                    "smallThumbnail": "http://books.google.com/books/content?id=mN2sNAEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
                    "thumbnail": "http://books.google.com/books/content?id=mN2sNAEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
                },
                "language": "ja",
                "previewLink": "http://books.google.co.jp/books?id=mN2sNAEACAAJ&dq=%E3%83%8E%E3%83%A9%E3%83%8D%E3%82%B3%E3%81%90%E3%82%93%E3%81%A0%E3%82%93&hl=&cd=1&source=gbs_api",
                "infoLink": "http://books.google.co.jp/books?id=mN2sNAEACAAJ&dq=%E3%83%8E%E3%83%A9%E3%83%8D%E3%82%B3%E3%81%90%E3%82%93%E3%81%A0%E3%82%93&hl=&source=gbs_api",
                "canonicalVolumeLink": "https://books.google.com/books/about/%E3%83%8E%E3%83%A9%E3%83%8D%E3%82%B3%E3%81%90%E3%82%93%E3%81%A0%E3%82%93%E3%83%91%E3%83%B3%E3%81%93%E3%81%86%E3%81%98.html?hl=&id=mN2sNAEACAAJ"
            },
            "saleInfo": {
                "country": "JP",
                "saleability": "NOT_FOR_SALE",
                "isEbook": false
            },
            "accessInfo": {
                "country": "JP",
                "viewability": "NO_PAGES",
                "embeddable": false,
                "publicDomain": false,
                "textToSpeechPermission": "ALLOWED",
                "epub": {
                    "isAvailable": false
                },
                "pdf": {
                    "isAvailable": false
                },
                "webReaderLink": "http://play.google.com/books/reader?id=mN2sNAEACAAJ&hl=&source=gbs_api",
                "accessViewStatus": "NONE",
                "quoteSharingAllowed": false
            },
            "searchInfo": {
                "textSnippet": "ノラネコぐんだんは、おいしそうなパンが気になり、ワンワンちゃんのパンこうじょうにしのびこみました。食いしん坊のノラネコぐんだんが見よう見まねでパンづくりに挑戦、 ..."
            }
        }
    ]
}
```

# 2. アプリでの実装

API のモデル、プロバイダ、リポジトリ、コントローラ、UI の実装をします。

## 2-1. モデルの実装

まずは、API から取得したデータを扱うためのモデルを定義します。freezed パッケージを使用して、Google Books API のレスポンスデータをモデル化します。これにより、データを型安全に操作でき、コードの可読性とメンテナンス性が向上します。

- **データモデルの作成**
  `GoogleBooks`, `Book`, `VolumeInfo`, `ImageLinks`の4つのクラスを作成し、Google Books API から取得するデータに対応するモデルにします。各クラスには、API から取得するデータのフィールドを定義します。  
- **コード生成**
  freezed と json_serializable を組み合わせて、必要な `fromJson` メソッドやコピー機能を自動生成します。
    

```dart:作成：lib/features/search/entities/google_books.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'google_books.freezed.dart';

part 'google_books.g.dart';

/// Google Books APIから取得したデータのルートオブジェクトを表すクラス。
///
/// 以下、項目。
/// - [items] : 書籍のリスト
@freezed
class GoogleBooks with _$GoogleBooks {
  const factory GoogleBooks({
    required List<Book> items,
  }) = _GoogleBooks;

  factory GoogleBooks.fromJson(Map<String, dynamic> json) =>
      _$GoogleBooksFromJson(json);
}

/// 各書籍の情報を表すクラス。
///
/// 以下、項目。
/// - [id] : 書籍のID
/// - [volumeInfo] : 書籍の詳細情報
@freezed
class Book with _$Book {
  const factory Book({
    required String id,
    required VolumeInfo volumeInfo,
  }) = _Book;

  factory Book.fromJson(Map<String, dynamic> json) => _$BookFromJson(json);
}

/// 書籍の詳細情報を表すクラス。
///
/// 以下、項目。
/// - [title] : 書籍のタイトル
/// - [authors] : 著者のリスト
/// - [publishedDate] : 出版日
/// - [description] : 書籍の説明
/// - [pageCount] : ページ数
/// - [imageLinks] : 画像リンク
@freezed
class VolumeInfo with _$VolumeInfo {
  const factory VolumeInfo({
    required String title,
    @Default([]) List<String> authors,
    String? publishedDate,
    String? description,
    int? pageCount,
    imageLinks,
  }) = _VolumeInfo;

  factory VolumeInfo.fromJson(Map<String, dynamic> json) =>
      _$VolumeInfoFromJson(json);
}

/// 書籍の画像リンクを表すクラス。
///
/// 以下、項目。
/// - [smallThumbnail] : 小さなサムネイル画像のURL
/// - [thumbnail] : サムネイル画像のURL
@freezed
class ImageLinks with _$ImageLinks {
  const factory ImageLinks({
    String? smallThumbnail,
    String? thumbnail,
  }) = _ImageLinks;

  factory ImageLinks.fromJson(Map<String, dynamic> json) =>
      _$ImageLinksFromJson(json);
}
```

## 2-2. プロバイダの実装

次に、API からデータを取得するためのプロバイダを定義します。riverpod を使用して状態管理を行い、retrofit を使用して API クライアントを実装します。

- **Dio の設定**
  `Dio` を用いて HTTP リクエストを行います。リクエストやレスポンスのログを出力するために `LogInterceptor` を追加します。
    
- **retrofit を使用した API クライアントの生成**
  retrofit を使用して、Google Books API と通信するクライアントを自動生成します。このクライアントを利用して、指定したクエリに基づいて書籍データを取得します。
    
```dart:作成：lib/features/search/repositories/google_books_api_client.dart
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../../../core/repositories/dio/constants.dart';
import '../entities/google_books.dart';

part 'google_books_api_client.g.dart';

/// Riverpodプロバイダを使用してGoogleBooksApiClientを提供する。
///
/// アプリケーションの全ライフサイクルを通じて生存する。
@Riverpod(keepAlive: true)
GoogleBooksApiClient googleBooksApiClient(GoogleBooksApiClientRef ref) {
  return GoogleBooksApiClient(
    Dio(dioDefaultOptions)
      ..interceptors.addAll([
        LogInterceptor(
          requestBody: true,
          responseBody: true,
        ),
      ]),
  );
}

/// Google Books API Clientインターフェース。
///
/// Google Booksの書籍情報を取得するためのメソッドを提供する。
@RestApi(baseUrl: 'https://www.googleapis.com/books/v1')
abstract class GoogleBooksApiClient {
  /// GoogleBooksApiClientのインスタンスを生成するファクトリコンストラクタ。
  ///
  /// DioインスタンスとベースURLを使用する。
  ///
  /// 項目は、以下。
  /// - [dio] : Dioインスタンス
  /// - [baseUrl] : ベースURL
  factory GoogleBooksApiClient(
    Dio dio, {
    String baseUrl,
  }) = _GoogleBooksApiClient;

  /// GoogleBooksの書籍一覧を取得する。
  ///
  /// 項目は、以下。
  /// - [query] : 検索クエリ
  /// - [startIndex] : 開始インデックス
  /// - [maxResults] : 最大取得件数
  /// - [apiKey] : APIキー
  @GET('/volumes')
  Future<GoogleBooks> getBooks(
    @Query('q') String query,
    @Query('startIndex') int? startIndex,
    @Query('maxResults') int? maxResults,
    @Query('key') String apiKey,
  );
}

```

## 2-3. リポジトリの実装

リポジトリ層を実装し、ビジネスロジックを管理します。ここでは、API クライアントを介してデータを取得し、アプリケーション内で利用できるようにします。

- **エラーハンドリング**
  API リクエストが失敗した場合に備えて、`DioException` をキャッチし、適切なエラーメッセージをログに記録し、カスタム例外をスローします。
    
- **GoogleBooksApiRepository**
  API クライアントからデータを取得し、アプリケーションに提供するためのメソッドを実装します。このリポジトリは、Riverpod プロバイダとして提供されます。
    
```dart:作成：lib/features/search/repositories/google_books_api_repository.dart
import 'dart:async';

import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../../../core/exceptions/app_exception.dart';
import '../../../core/extensions/exception_extension.dart';
import '../../../core/utils/logger.dart';
import '../entities/google_books.dart';
import 'google_books_api_client.dart';

part 'google_books_api_repository.g.dart';

/// GoogleBooksApiRepositoryを提供するRiverpodプロバイダ。
///
/// アプリケーションの全ライフサイクルを通じて生存する。
@Riverpod(keepAlive: true)
GoogleBooksApiRepository googleBooksApiRepository(
  GoogleBooksApiRepositoryRef ref,
) {
  return GoogleBooksApiRepository(ref);
}

class GoogleBooksApiRepository {
  GoogleBooksApiRepository(
    Ref ref,
  ) : _client = ref.read(googleBooksApiClientProvider);

  final GoogleBooksApiClient _client;

  /// GoogleBooks APIを使用して書籍のリストを取得する。
  ///
  /// [query]は、検索クエリ。
  /// [startIndex]は、開始インデックス。
  /// [maxResults]は、最大取得件数。
  ///
  /// 返り値は、書籍情報のリストを含むFuture。
  /// 取得に失敗した場合は、AppExceptionをスローする。
  Future<List<Book>> fetchBooks({
    required String query,
    int? startIndex,
    int? maxResults,
  }) async {
    // const apiKey = String.fromEnvironment('googleBooksApiKey');
    // APIキーを設定（実際のプロジェクトでは環境変数から取得することを推奨）
    const apiKey = '1234567890abcdefghijklmnopqrstuvwxyABCD';
    try {
      final result = await _client.fetchBooks(
        query,
        startIndex,
        maxResults,
        apiKey,
      );
      return result.items;
    } on DioException catch (e) {
      final response = e.response;
      logger.shout(
        'statusCode: ${response?.statusCode}, '
        'message: ${response?.statusMessage}',
      );
      throw AppException.error(e.message ?? 'error');
    } on Exception catch (e) {
      logger.shout(e);
      throw AppException.error(e.errorMessage);
    }
  }
}
```

## 2-4. コントローラの実装

UI とリポジトリ層をつなぐコントローラを実装します。

```dart:作成：lib/features/search/use_cases/search_books_controller.dart
import 'dart:async';

import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../entities/google_books.dart';
import '../repositories/google_books_api_repository.dart';

part 'search_books_controller.g.dart';

/// Google Booksの検索結果を管理するコントローラー。
@riverpod
class SearchBooksController extends _$SearchBooksController {
  static int get _pageCount => 20;

  int _lastStartIndex = 0;

  /// インスタンス生成時に取得する。
  @override
  Future<List<Book>> build() async {
    final length = state.value?.length ?? 0;
    final data = await ref.watch(googleBooksApiRepositoryProvider).fetchBooks(
      query: 'ノラネコぐんだん',
      startIndex: 0,
      maxResults: length > _pageCount ? length : _pageCount,
    );
    if (data.isNotEmpty) {
      _lastStartIndex = data.length;
    } else {
      _lastStartIndex = 0;
    }
    return data;
  }

  /// ページング取得（リストの最下部到達時に使用する）
  Future<void> onFetchMore() async {
    final result = await AsyncValue.guard(() async {
      final data = await ref.read(googleBooksApiRepositoryProvider).fetchBooks(
        query: 'ノラネコぐんだん',
        startIndex: _lastStartIndex,
        maxResults: _pageCount,
      );
      if (data.isNotEmpty) {
        _lastStartIndex += data.length;
      }
      final previousState = await future;
      return [...previousState, ...data];
    });

    state = result;
  }
}
```

これにより、UI 側で検索機能やページング機能を利用できるようになります。

## 2-5. UI の実装

取得したデータを表示するための UI を構築します。
検索結果を表示するリストビューと、データ取得時のエラーメッセージ表示、リフレッシュ機能を実装します。

- **タブでのページ遷移の構築**
  ユーザーが検索タブをタップすると検索ページへ遷移するようにします。  
- **リストビューの構築**
  取得した書籍データをリスト形式で表示し、ユーザーが書籍をタップすると詳細ページへ遷移するようにします。  
- **エラーメッセージの表示**
  データ取得時にエラーが発生した場合に、ユーザーに適切なエラーメッセージを表示し、再試行ボタンを提供します。
- **リフレッシュ機能**
  Pull-to-Refresh を使用して、データをリフレッシュする機能を追加します。

```diff_dart:編集：lib/features/app_wrapper/pages/main_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:page_transition/page_transition.dart';

import '../../../core/utils/tab_tap_operation_provider.dart';
import '../../github_users/pages/github_users_page.dart';
import '../../home/pages/home_page.dart';
import '../../memo/pages/memo_page.dart';
+ import '../../search/pages/search_books_page.dart';
import '../../setting/pages/setting_page.dart';
import 'widgets/tab_navigator.dart';

class MainPage extends HookConsumerWidget {
  const MainPage({super.key});

  static String get pageName => 'main';

  static String get pagePath => '/$pageName';

  /// go_routerの画面遷移
  static void go(BuildContext context) {
    context.go(pagePath);
  }

  /// 従来の画面遷移
  static Future<void> showNav1(BuildContext context) =>
      Navigator.of(context, rootNavigator: true).pushReplacement<void, void>(
        PageTransition(
          type: PageTransitionType.fade,
          child: const MainPage(),
          duration: const Duration(milliseconds: 500),
          settings: RouteSettings(name: pageName),
        ),
      );

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final widgetsState =
        useState<List<(GlobalKey<NavigatorState>, String, Widget)>>(
      [
        (
          GlobalKey<NavigatorState>(),
          HomePage.pageName,
          const HomePage(),
        ),
        (
          GlobalKey<NavigatorState>(),
          GithubUsersPage.pageName,
          const GithubUsersPage()
        ),
+        (
+          GlobalKey<NavigatorState>(),
+          SearchBooksPage.pageName,
+          const SearchBooksPage()
+        ),
        (
          GlobalKey<NavigatorState>(),
          MemoPage.pageName,
          const MemoPage(),
        ),
        (
          GlobalKey<NavigatorState>(),
          SettingPage.pageName,
          const SettingPage(),
        ),
      ],
    );
    final widgets = widgetsState.value;

    final selectedTabIndexState = useState(0);
    final selectedTabIndex = selectedTabIndexState.value;

    return PopScope(
      canPop: false,
      onPopInvoked: (didPop) async {
        if (didPop) {
          return;
        }
        final keyTab = widgetsState.value[selectedTabIndex].$1;
        if (keyTab.currentState != null && keyTab.currentState!.canPop()) {
          await keyTab.currentState!.maybePop();
        }
      },
      child: Scaffold(
        resizeToAvoidBottomInset: false,
        body: IndexedStack(
          index: selectedTabIndex,
          children: List.generate(
            widgets.length,
            (index) => TabNavigator(
              navigatorKey: widgets[index].$1,
              page: widgets[index].$3,
            ),
          ),
        ),
        bottomNavigationBar: NavigationBar(
          onDestinationSelected: (int index) {
            /// 同じタブが選択されたことを通知する
            if (index == selectedTabIndex) {
              final pageName = widgets[index].$2;
              ref
                  .read(tabTapOperationProviders(pageName))
                  .call(TabTapOperationType.duplication);
            }

            // タブを切り替える
            selectedTabIndexState.value = index;
          },
          selectedIndex: selectedTabIndex,
          destinations: const <Widget>[
            NavigationDestination(
              selectedIcon: Icon(Icons.home),
              icon: Icon(Icons.home_outlined),
              label: 'タブ1',
            ),
            NavigationDestination(
              selectedIcon: Icon(Icons.people),
              icon: Icon(Icons.people_outlined),
              label: 'タブ2',
            ),
+            NavigationDestination(
+              selectedIcon: Icon(Icons.search),
+              icon: Icon(Icons.search_outlined),
+              label: '検索',
+            ),
            NavigationDestination(
              selectedIcon: Icon(Icons.edit),
              icon: Icon(Icons.edit_outlined),
              label: 'タブ3',
            ),
            NavigationDestination(
              selectedIcon: Icon(Icons.settings),
              icon: Icon(Icons.settings_outlined),
              label: 'タブ4',
            ),
          ],
        ),
      ),
    );
  }
}

```

```dart:作成：lib/features/search/pages/search_books_page.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
import 'package:url_launcher/url_launcher.dart';

import '../../../core/custom_hooks/use_effect_once.dart';
import '../../../core/custom_hooks/use_refresh_controller.dart';
import '../../../core/extensions/context_extension.dart';
import '../../../core/extensions/scroll_controller_extension.dart';
import '../../../core/utils/tab_tap_operation_provider.dart';
import '../../../core/widgets/images/thumbnail.dart';
import '../../../core/widgets/smart_refresher/smart_refresher_custom.dart';
import '../use_cases/search_books_controller.dart';
import 'widgets/error_message.dart';

class SearchBooksPage extends HookConsumerWidget {
  const SearchBooksPage({super.key});

  static String get pageName => 'search_books';

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final searchBooks = ref.watch(searchBooksControllerProvider);
    final scrollController = useScrollController();
    final refreshController = useRefreshController();
    final tabTapOperation = ref.watch(tabTapOperationProviders(pageName));

    useEffectOnce(() {
      tabTapOperation.addListener((value) {
        if (value == TabTapOperationType.duplication) {
          scrollController.animateToTop();
        }
      });
      return null;
    });

    return Scaffold(
      appBar: AppBar(
        title: Text(
          'Search Books',
          style: context.subtitleStyle.copyWith(
            fontWeight: FontWeight.bold,
            color: Colors.white,
          ),
        ),
        centerTitle: true,
      ),
      body: searchBooks.when(
        data: (items) {
          return SmartRefresher(
            header: const SmartRefreshHeader(),
            footer: const SmartRefreshFooter(),
            enablePullUp: true,
            controller: refreshController,
            physics: const BouncingScrollPhysics(),
            onRefresh: () async {
              ref.invalidate(searchBooksControllerProvider);
              refreshController.refreshCompleted();
            },
            onLoading: () async {
              await ref
                  .read(searchBooksControllerProvider.notifier)
                  .onFetchMore();
              refreshController.loadComplete();
            },
            child: ListView.separated(
              controller: scrollController,
              itemBuilder: (BuildContext context, int index) {
                final data = items[index];
                return ListTile(
                  leading: Thumbnail(
                    width: 40,
                    url: data.volumeInfo.imageLinks?.thumbnail,
                  ),
                  title: Text(
                    data.volumeInfo.title,
                    style: context.bodyStyle,
                  ),
                  subtitle: Text(
                    data.volumeInfo.authors.join(', '),
                    style: context.smallStyle,
                  ),
                  trailing: const Icon(
                    Icons.arrow_forward_ios,
                    size: 16,
                  ),
                  onTap: () {
                    final infoUrl =
                        'https://books.google.com/books?id=${data.id}';
                    launchUrl(Uri.parse(infoUrl));
                  },
                );
              },
              separatorBuilder: (BuildContext context, int index) {
                return const Divider(height: 1);
              },
              itemCount: items.length,
            ),
          );
        },
        error: (e, _) => ErrorMessage(
          message: e.toString(),
          onTapRetry: () async {
            ref.invalidate(searchBooksControllerProvider);
          },
        ),
        loading: () => const Center(
          child: CupertinoActivityIndicator(),
        ),
      ),
    );
  }
}

```

```dart:作成：lib/features/search/pages/widgets/error_message.dart
import 'package:flutter/material.dart';

import '../../../../core/extensions/context_extension.dart';
import '../../../../core/res/button_style.dart';

class ErrorMessage extends StatelessWidget {
  const ErrorMessage({
    required this.message,
    required this.onTapRetry,
    super.key,
  });

  final String message;
  final VoidCallback onTapRetry;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            'エラーが発生しました\n$message',
            style: context.bodyStyle.copyWith(
              color: Colors.red,
            ),
            textAlign: TextAlign.center,
          ),
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: FilledButton(
              style: ButtonStyles.normal(),
              onPressed: onTapRetry,
              child: Text(
                '再試行',
                style: context.bodyStyle.copyWith(
                  color: Colors.white,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
```

## 補足. build_runner による自動生成

なお、freezed, riverpod について自動生成するには、以下のコマンドを実行します。

```bash
flutter pub run build_runner build
```

```bash:コマンド実行時ログ
# r_yamate @ mbp in ~/development/picture_book_log on git:feature/search x [16:44:55] 
$ flutter pub run build_runner build
Deprecated. Use `dart run` instead.
[INFO] Generating build script completed, took 637ms
FlutterGen v5.4.0 Loading ... pubspec.yaml
[INFO] Reading cached asset graph completed, took 679ms
[INFO] Checking for updates since last build completed, took 2.1s
[INFO] Running build completed, took 33.1s
[INFO] Caching finalized dependency graph completed, took 395ms
[INFO] Succeeded after 33.5s with 16 outputs (65 actions)

```

```bash:確認：上記で実装したファイル、自動生成されたファイル
# r_yamate @ mbp in ~/development/picture_book_log/lib/features/search on git:feature/search x [23:30:13] 
$ tree
.
├── entities
│   ├── google_books.dart
│   ├── google_books.freezed.dart
│   └── google_books.g.dart
├── pages
│   ├── search_books_page.dart
│   └── widgets
│       └── error_message.dart
├── repositories
│   ├── google_books_api_client.dart
│   ├── google_books_api_client.g.dart
│   ├── google_books_api_repository.dart
│   └── google_books_api_repository.g.dart
└── use_cases
    ├── search_books_controller.dart
    └── search_books_controller.g.dart

6 directories, 11 files
```

# 3. 動作確認

プロジェクトをビルドし、実際に Google Books API からデータを取得して表示できるか確認します。検索結果が正しく表示されるかをチェックします。

<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/527226/790c3f1d-5436-6098-90dd-9796cb6b58b3.png">

現状は「ノラネコぐんだん」で固定していますが、検索フィールドにキーワードを入力する仕様にするつもりです。



※ 2024/08/29 追記
↓ 検索フィールドにキーワードを入力する仕様にする記事はこちら

https://qiita.com/ryamate/items/a33bfeb86f710e10e7e7

# おわりに

Flutter アプリケーションで Google Books API を利用する方法についてまとめました。

ありがとうございました。