📚

【Flutter】Ruby × Flutter でサンプルアプリを作る

2024/12/05に公開

初めに

今回は Ruby と Flutter を組み合わせたサンプルアプリを作っていきたいと思います。
バックエンド側を Ruby on Rails で作成し、Flutter 側から呼び出せるような形にしていきます。

記事の対象者

  • Flutter 学習者
  • Ruby, Ruby on Rails を触ってみたい方

目的

今回は先述の通り、バックエンドを Ruby、フロントエンドを Flutter で実装して、簡単なアプリを作っていきます。筆者は Ruby, Ruby on Rails 初学者の段階なので、今回は複雑な実装は行わず、ざっくりどのような手順で実装していけば良いかをまとめるような形にしてみたいと思います。

最終的には以下の動画のように本の管理ができるアプリを作ってみたいと思います。

https://youtube.com/shorts/oyb4MkOyK1g

今回実装したコードは以下で公開しているので、適宜ご参照ください。

https://github.com/Koichi5/ruby_flutter_sample

実装

実装は以下の手順で進めていきます。

  1. Ruby 側の実装
  2. Flutter 側の実装

1. Ruby 側の実装

まずは Ruby 側の実装を進めていきます。
Ruby 側の実装は以下の手順で進めていきます。

  1. Ruby, Ruby on Rails, プロジェクトのセットアップ
  2. Model の作成
  3. Controller の作成
  4. 動作確認

1. Ruby, Ruby on Rails, プロジェクトのセットアップ

まずはプロジェクトのセットアップを行います。
以下のコマンドでプロジェクトを作成しておきます。筆者の手元では「ruby_flutter_sample」という名前で作成しています。作成できたら、そのプロジェクトのルートディレクトリに移動しておきます。

flutter create { プロジェクト名 }

次に Ruby, Ruby on Rails のセットアップを行います。
Ruby on Rails をインストールしていない場合は以下のコマンドでインストールしておきます。

gem install rails

次に以下のコマンドで Ruby on Rails の新しいプロジェクトを作成していきます。
--api をつけることでAPI専用の Rails アプリケーションを生成することができます。
今回は Flutter プロジェクトの中で backend という名前で生成しておきます。
生成が完了したら backend ディレクトリに移動しておきます。

rails new backend --api

次に backend ディレクトリで以下のコマンドを実行して、インストールされているパッケージなどを一括でインストールします。

bundle install

これでプロジェクトのセットアップは完了です。

2. Model の作成

次に Model の作成に移ります。
以下のコマンドで Book モデルを作成します。
rails g model [モデル名] [属性名:データ型 属性名:データ型 ...] でモデルの雛形を用いてモデルを生成することができます。
今回は以下の項目を持つデータを生成してみます。

  • title: 本のタイトル(文字列)
  • publisher: 本の出版社(文字列)
  • description: 本の説明文(文字列)
  • page_count: 本のページ数(整数値)
  • image_url: 本の書影の画像URL(文字列)
rails g model Book title:string publisher:string description:string page_count:integer image_url:string

正常に動作すると app/models/book.rb, db/migrate/ などのパスにファイルが生成されていることがわかります。

次に以下のコマンドを実行して migration ファイルの内容をデータベースに反映させます。

rails db:migrate

最後に backend/db/migrate/yyyymmddhhmmss_create_books.rb で設定されている各プロパティに null: false をつけて全てのプロパティがNullを許容しないように設定しておきます。

backend/db/migrate/yyyymmddhhmmss_create_books.rb
ActiveRecord::Schema[7.2].define(version: 2024_11_14_135508) do
  create_table "books", force: :cascade do |t|
    t.string "title", null: false
    t.string "publisher", null: false
    t.string "description", null: false
    t.integer "page_count", null: false
    t.string "image_url", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end
end

これでモデルの設定は完了です。

3. Controller の作成

次に以下のコマンドを実行して Controller を作成していきます。
Model を作成した時と同様で rails g コマンドを用います。
以下のコマンドで、Book の追加、一覧取得、変更、削除を行う Book のコントローラーを作成します。コントローラーの作成が完了したら以下のような出力結果になり、それぞれのメソッドと books_controller.rb が作成されていることがわかります。

rails g controller Books register index update delete

出力結果
    create  app/controllers/books_controller.rb
    route  get "books/register"
          get "books/index"
          get "books/update"
          get "books/delete"
    invoke  test_unit
    create    test/controllers/books_controller_test.rb

次に backend/config/routes.rb を以下のように変更していきます。
ここでは、それぞれのHTTPメソッドとエンドポイントURLに対応する処理を記述しています。

post "books/register", to: 'books#register' を例にとってみてみると、「books/register に対して POST メソッドを実行すると、 Books コントローラーの register メソッドを実行する」という意味になります。
to の後に実行する処理が記述されており、これらの Books コントローラーの処理はこれから実装していきます。

backend/config/routes.rb
Rails.application.routes.draw do
  post "books/register", to: 'books#register'
  get "books/index", to: 'books#index'
  put "books/update/:id", to: 'books#update'
  delete "books/delete/:id", to: 'books#delete'
  get "up" => "rails/health#show", as: :rails_health_check
end

次に作成した Controller を編集していきます。

まずは本の追加メソッドを実装します。
book_params として本のデータを受け取り、それをもとに Book の新しいインスタンスを生成しています。そして、そのインスタンスをデータベースに保存するために save を実行しています。
save が成功した場合は保存したデータとステータスコード 201 を返すようにして、失敗した場合は失敗したことを示すメッセージとステータスコード 400 を返すようにしています。

app/controllers/books_controller.rb
class BooksController < ApplicationController
  def register
+   book = Book.new(book_params)
+   if book.save
+     render json: { book: book }, status: 201
+   else
+     render json: { message: 'Failed to register book' }, status: 400
+   end
  end

次に本の一覧取得メソッドを実装していきます。
Book.all で Book レコードを全て取得して books という変数に代入しています。
books が存在しない場合はその旨を表すメッセージとステータスコード 400 を返すようにして、存在する場合は books のデータとステータスコード 200 を返すようにしています。

def index
+ books = Book.all
+ if books.empty?
+   render json: { message: 'No books found' }, status: 400
+ else
+   render json: { books: books }, status: 200
+ end
end

次に本の編集メソッドを実装していきます。
Book.find_by(id: params[:id]) の部分でパラメーターから受け取った Book のIDをもとに編集する Book を見つけ出します。
もしIDに合致する Book が見つからなかった場合は register メソッドの実装と同様に新たに Book インスタンスを生成して、それを book.save でデータベースに保存するようにしています。
IDに合致する Book が見つかった場合は update メソッドで受け取った Book のデータを渡しています。データの更新に成功した場合はその更新内容とステータスコード 200 を返し、更新に失敗した場合は失敗した旨のメッセージとステータスコード 400 を返すようにしています。

def update
+ book = Book.find_by(id: params[:id])
+ if book.nil?
+   book = Book.new(book_params)
+   if book.save
+     render json: { book: book }, status: 201
+   else
+     render json: { message: 'Failed to update book' }, status: 400
+   end
+ else
+   if book.update(book_params)
+     render json: { book: book }, status: 200
+   else
+     render json: { message: 'Failed to update book' }, status: 400
+   end
+ end
end

次に削除メソッドを実装していきます。
update メソッドの実装と同様に Book.find_by(id: params[:id]) の部分でパラメーターから受け取った Book のIDをもとに削除する Book を見つけ出します。
もしIDに合致する Book が見つけられなかった場合はその旨を表すメッセージとステータスコード 400 を返し、IDに合致する Book が見つかった場合は book.destroy で見つけた Book のデータを削除しています。削除に成功した場合、返す内容はないためステータスコード 204 を返し、失敗した場合はメッセージとステータスコード 400 を返しています。

def delete
+ book = Book.find_by(id: params[:id])
+ if book.nil?
+   render json: { message: 'No book found' }, status: 400
+ else
+   if book.destroy
+   render status: 204
+   else
+     render json: { message: 'Failed to delete book' }, status: 400
+   end
+ end
end

最後に book_params の定義を行います。
private とすることでコントローラー内からのみアクセス可能にしています。
params.permit ではストロングパラメータ(Strong Parameters)と呼ばれるRailsのセキュリティ機能を利用して、クライアントから送信されるパラメータのうち、許可された属性のみを抽出・許可しています。今回の場合は Book のデータに合わないパラメータを含めることができないようにしています。このようにすることで、明示的にパラメータを管理し、意図しないデータの登録を防止しています。

+  private
+
+  def book_params
+    params.permit(:title, :publisher, :description, :page_count, :image_url)
+  end

これで Controller の実装は完了です。

4. 動作確認

最後に実装した内容の動作確認を行います。
作成した Ruby のプロジェクトのディレクトリ(筆者の手元では backend ディレクトリ)で以下のコマンドを実行します。 Rails には標準で Puma というWebサーバが設けられており、このコマンドで Puma を起動することができます。動作確認はこのコマンドを実行した状態で行います。
なお、Pumaはデフォルトで3000番ポートを使用するので、http://localhost:3000 でアプリケーションにアクセスできます。

rails s

それぞれのメソッドの検証を行います。

本の追加
まずは Book のデータ追加を行う register の検証をしていきます。
Postman などのツールで以下の設定でリクエストを送ってみます。
HTTPメソッド : POST
エンドポイントURL : http://127.0.0.1:3000/books/register
Request Body :

{
    "title": "Team Geek",
    "publisher": "オライリージャパン",
    "description": "複数のプログラマが関わる場合、優れたコードを書くだけではプロジェクトは成功しません。全員が最終目標に向かって協力することが重要であり、チームの協力関係はプロジェクト成功のカギとなります。本書は、Subversionをはじめ、たくさんのフリーソフトウェア開発に関わり、その後Googleでプログラマを経てリーダーを務めるようになった著者が、「エンジニアが他人とうまくやる」コツを紹介。「チームを作る三本柱」や「チーム文化のつくり方」から「有害な人への対処法」まで、エンジニアに求められる社会性について楽しい逸話とともに解説します。",
    "page_count": 228,
    "image_url": "https://m.media-amazon.com/images/I/91YeFKY+EgL._SY522_.jpg"
}

実行結果 :
以下のように登録した本のデータに加えて、 id, created_at, updated_at などが加えられたデータが返却されれば追加処理は完了です。

{
    "book": {
        "id": 6,
        "title": "Team Geek",
        "publisher": "オライリージャパン",
        "description": "複数のプログラマが関わる場合、優れたコードを書くだけではプロジェクトは成功しません。全員が最終目標に向かって協力することが重要であり、チームの協力関係はプロジェクト成功のカギとなります。本書は、Subversionをはじめ、たくさんのフリーソフトウェア開発に関わり、その後Googleでプログラマを経てリーダーを務めるようになった著者が、「エンジニアが他人とうまくやる」コツを紹介。「チームを作る三本柱」や「チーム文化のつくり方」から「有害な人への対処法」まで、エンジニアに求められる社会性について楽しい逸話とともに解説します。",
        "page_count": 228,
        "image_url": "https://m.media-amazon.com/images/I/91YeFKY+EgL._SY522_.jpg",
        "created_at": "2024-11-15T08:41:06.242Z",
        "updated_at": "2024-11-15T08:41:06.242Z"
    }
}



本の一覧取得
次に Book のデータ一覧取得を行う index の検証をしていきます。
HTTPメソッド : GET
エンドポイントURL : http://127.0.0.1:3000/books/index
Request Body : なし

実行結果 :
以下のように登録した本の一覧データが取得できれば完了です。

{
    "books": [
        {
            "id": 1,
            "title": "APIデザイン・パターン",
            "publisher": "マイナビ出版",
            "description": "APIとはアプリケーション、サービス、コンポーネントがどのように通信するかを定義する仕様です。本書『APIデザイン・パターン』は、Web APIを構築するための安全かつ柔軟で再利用可能なパターンを提供するために執筆されました。一般的な設計原則の説明からはじめ、APIを構築する際の仕様、デザイン・パターンを紹介していきます。Manning Publishing: API Design Patterns の翻訳書。",
            "page_count": 528,
            "image_url": "https://m.media-amazon.com/images/I/61X9RsHxS5L._SY522_.jpg",
            "created_at": "2024-11-14T14:27:49.162Z",
            "updated_at": "2024-11-14T16:31:16.036Z"
        },
        {
            "id": 2,
            "title": "Clean Architecture",
            "publisher": "KADOKAWA",
            "description": "アーキテクチャのルールはどれも同じである!書いているコードが変わらないのだから、どんな種類のシステムでもソフトウェアアーキテクチャのルールは同じ。ソフトウェアアーキテクチャのルールとは、プログラムの構成要素をどのように組み立てるかのルールである。構成要素は普遍的で変わらないのだから、それらを組み立てるルールもまた、普遍的で変わらないのである。(本書「序文」より)",
            "page_count": 352,
            "image_url": "https://m.media-amazon.com/images/I/91TJotanDbL._SY522_.jpg",
            "created_at": "2024-11-14T14:39:41.054Z",
            "updated_at": "2024-11-14T14:39:41.054Z"
        },

省略



本の更新
次に Book のデータ更新を行う update の検証をしていきます。
以下では ID が 1 で登録されている Book のデータのタイトルを「APIデザイン・パターン(変更済み)」にしてみています。
HTTPメソッド : PUT
エンドポイントURL : http://127.0.0.1:3000/books/update/1
Request Body :

{
    "id": 1,
    "title": "APIデザイン・パターン(変更済み)",
    "publisher": "マイナビ出版",
    "description": "APIとはアプリケーション、サービス、コンポーネントがどのように通信するかを定義する仕様です。本書『APIデザイン・パターン』は、Web APIを構築するための安全かつ柔軟で再利用可能なパターンを提供するために執筆されました。一般的な設計原則の説明からはじめ、APIを構築する際の仕様、デザイン・パターンを紹介していきます。Manning Publishing: API Design Patterns の翻訳書。",
    "page_count": 528,
    "image_url": "https://m.media-amazon.com/images/I/61X9RsHxS5L._SY522_.jpg",
            "created_at": "2024-11-14T14:27:49.162Z",
            "updated_at": "2024-11-14T16:31:16.036Z"
},

実行結果 :
以下のように変更された Book のデータが返ってきていれば成功です。

{
    "book": {
        "title": "APIデザイン・パターン(変更済み)",  // タイトルが変更されている
        "publisher": "マイナビ出版",
        "description": "APIとはアプリケーション、サービス、コンポーネントがどのように通信するかを定義する仕様です。本書『APIデザイン・パターン』は、Web APIを構築するための安全かつ柔軟で再利用可能なパターンを提供するために執筆されました。一般的な設計原則の説明からはじめ、APIを構築する際の仕様、デザイン・パターンを紹介していきます。Manning Publishing: API Design Patterns の翻訳書。",
        "page_count": 528,
        "image_url": "https://m.media-amazon.com/images/I/61X9RsHxS5L._SY522_.jpg",
        "id": 1,
        "created_at": "2024-11-14T14:27:49.162Z",
        "updated_at": "2024-11-15T09:19:10.530Z"
    }
}



本の削除
まずは Book のデータ削除を行う delete の検証をしていきます。
以下では ID が 6 で登録されている Book のデータを削除しています。
HTTPメソッド : DELETE
エンドポイントURL : http://127.0.0.1:3000/books/delete/6
Request Body : なし

実行結果 :
204 No Content のステータスコードが返ってきていれば成功です。



これで Ruby 側の実装は完了です。
次の Flutter 側の実装では今までで実装した機能を使う処理を書いていきます。

2. Flutter 側の実装

次に Flutter 側の実装に移っていきます。
Flutter 側の実装は以下の手順で進めていきます。

  1. 準備
  2. Model の実装
  3. API の実装
  4. Provider の実装
  5. UIの実装

1. 準備

まずは準備として必要なパッケージを追加していきます。
以下のパッケージの最新バージョンを pubspec.yamlに記述します。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  http: ^1.2.2
  flutter_hooks: ^0.20.5
  hooks_riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^4.0.0
  riverpod_generator: ^2.6.2
  build_runner: ^2.4.13

または

以下をターミナルで実行

flutter pub add http flutter_hooks hooks_riverpod riverpod_annotation
flutter pub add -d riverpod_generator build_runner

これで準備は完了です。

2. Model の実装

まずは Flutter 側でも Book のモデルを扱えるように実装していきます。
lib/models ディレクトリに book.dart を作成して以下のように編集していきます。
Ruby 側で定義したデータと同様になっているかと思います。

lib/models/book.dart
class Book {
  final int id;
  final String title;
  final String publisher;
  final String description;
  final int pageCount;
  final String imageUrl;
  final DateTime createdAt;
  final DateTime updatedAt;

  Book({
    required this.id,
    required this.title,
    required this.publisher,
    required this.description,
    required this.pageCount,
    required this.imageUrl,
    required this.createdAt,
    required this.updatedAt,
  });

  factory Book.fromJson(Map<String, dynamic> json) {
    return Book(
      id: json['id'],
      title: json['title'],
      publisher: json['publisher'],
      description: json['description'],
      pageCount: json['page_count'],
      imageUrl: json['image_url'],
      createdAt: DateTime.parse(json['created_at']),
      updatedAt: DateTime.parse(json['updated_at']),
    );
  }
}

これで Model の実装は完了です。

3. API の実装

次に API の実装に移ります。
lib/services ディレクトリに api_service.dart を作成して、以下のように編集します。

lib/services/api_service.dart
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/book.dart';

class ApiService {
  final String baseUrl;

  ApiService({required this.baseUrl});

  Future<List<Book>> fetchBooks() async {
    final response = await http.get(Uri.parse('$baseUrl/books/index'));

    if (response.statusCode == 200) {
      final Map<String, dynamic> data = json.decode(response.body);
      final List<dynamic> booksJson = data['books'];
      return booksJson.map((json) => Book.fromJson(json)).toList();
    } else {
      throw Exception('Failed to load books: ${response.statusCode}');
    }
  }

  Future<Book> addBook(Map<String, dynamic> bookData) async {
    final response = await http.post(
      Uri.parse('$baseUrl/books/register'),
      headers: {'Content-Type': 'application/json'},
      body: json.encode(bookData),
    );

    if (response.statusCode == 201) {
      final Map<String, dynamic> data = json.decode(response.body);
      return Book.fromJson(data['book']);
    } else {
      throw Exception('Failed to add book: ${response.statusCode}');
    }
  }

  Future<Book> updateBook(int id, Map<String, dynamic> bookData) async {
    final response = await http.put(
      Uri.parse('$baseUrl/books/update/$id'),
      headers: {'Content-Type': 'application/json'},
      body: json.encode(bookData),
    );

    if (response.statusCode == 200) {
      final Map<String, dynamic> data = json.decode(response.body);
      return Book.fromJson(data['book']);
    } else {
      throw Exception('Failed to update book: ${response.statusCode}');
    }
  }

  Future<void> deleteBook(int id) async {
    final response = await http.delete(
      Uri.parse('$baseUrl/books/delete/$id'),
    );

    if (response.statusCode != 204) {
      throw Exception('Failed to delete book: ${response.statusCode}');
    }
  }
}

それぞれ詳しくみていきます。

以下では外部から baseUrl を取得するようにしています。
デバッグ環境のみで実装していますが、環境に応じてベースとなるURLを切り替えられるように外部から取得しています。

class ApiService {
  final String baseUrl;

  ApiService({required this.baseUrl});

以下では Book の一覧取得を行う fetchBooks メソッドを実装しています。
http.get で引数に入れたエンドポイントに対してGETメソッドを実行して、その結果を response に代入しています。
response のステータスコードが 200 の場合、つまり取得処理が成功した場合は Book のJSONデータを抽出して、 Book 型に変換して返すようにしています。

Future<List<Book>> fetchBooks() async {
  final response = await http.get(Uri.parse('$baseUrl/books/index'));

  if (response.statusCode == 200) {
    final Map<String, dynamic> data = json.decode(response.body);
    final List<dynamic> booksJson = data['books'];
    return booksJson.map((json) => Book.fromJson(json)).toList();
  } else {
    throw Exception('Failed to load books: ${response.statusCode}');
  }
}

以下では Book の追加処理を行う addBook メソッドを実装しています。
http.post でPOSTメソッドを実行しています。
headers{'Content-Type': 'application/json'} を指定することでサーバーに対してリクエストボディがJSONデータであることを明示的に伝えています。
body には追加したい Book のデータをJSON形式に変換して渡しています。

Future<Book> addBook(Map<String, dynamic> bookData) async {
  final response = await http.post(
    Uri.parse('$baseUrl/books/register'),
    headers: {'Content-Type': 'application/json'},
    body: json.encode(bookData),
  );

  if (response.statusCode == 201) {
    final Map<String, dynamic> data = json.decode(response.body);
    return Book.fromJson(data['book']);
  } else {
    throw Exception('Failed to add book: ${response.statusCode}');
  }
}

以下では Book の変更処理を行う updateBook メソッドを実装しています。
http.put でPUTメソッドを実行しています。
引数として変更したい Book のIDと変更後のデータである bookData を受け取り、IDはパスに含め、 bookDatabody に含めています。
変更が成功した場合は変更後のデータを返却し、失敗した場合は例外を投げるようにしています。

Future<Book> updateBook(int id, Map<String, dynamic> bookData) async {
  final response = await http.put(
    Uri.parse('$baseUrl/books/update/$id'),
    headers: {'Content-Type': 'application/json'},
    body: json.encode(bookData),
  );

  if (response.statusCode == 200) {
    final Map<String, dynamic> data = json.decode(response.body);
    return Book.fromJson(data['book']);
  } else {
    throw Exception('Failed to update book: ${response.statusCode}');
  }
}

以下では Book の削除処理を行う deleteBook メソッドを実装しています。
http.delete でDELETEメソッドを実行しています。
引数として削除したい Book のIDを受け取り、パスに含めています。
削除に失敗した場合のみ例外を投げるようにしています。

Future<void> deleteBook(int id) async {
  final response = await http.delete(
    Uri.parse('$baseUrl/books/delete/$id'),
  );

  if (response.statusCode != 204) {
    throw Exception('Failed to delete book: ${response.statusCode}');
  }
}

これで API の実装は完了です。

4. Provider の実装

次に Provider の実装を行います。

lib/providers/ ディレクトリで api_service_provider.dart を作成して以下のように編集します。
先程 ApiService を実装した際に baseUrl を外部から受け取る形にしました。ここでは http://localhost:3000 を渡して、画面側から参照できるようにしておきます。

lib/providers/api_service_provider.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:swagger_ruby_flutter_sample/services/api_service.dart';

part 'api_service_provider.g.dart';


ApiService apiService(Ref ref) {
  // 環境に応じてbaseUrlを変更
  return ApiService(baseUrl: 'http://localhost:3000');
}

次に、同じ lib/providers/ ディレクトリで books_notifier.dart を作成して以下のように編集します。

lib/providers/books_notifier.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:swagger_ruby_flutter_sample/providers/api_service_provider.dart';
import '../models/book.dart';
import '../services/api_service.dart';

part 'books_notifier.g.dart';


class BooksNotifier extends _$BooksNotifier {
  late final ApiService apiService;

  
  Future<List<Book>> build() async {
    apiService = ref.read(apiServiceProvider);
    return await apiService.fetchBooks();
  }

  Future<void> addBook(Map<String, dynamic> bookData) async {
    state = const AsyncValue.loading();
    try {
      final newBook = await apiService.addBook(bookData);
      final currentBooks = state.value ?? [];
      state = AsyncValue.data([...currentBooks, newBook]);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  Future<void> updateBook(int id, Map<String, dynamic> bookData) async {
    state = const AsyncValue.loading();
    try {
      final updatedBook = await apiService.updateBook(id, bookData);
      final currentBooks = state.value ?? [];
      final index = currentBooks.indexWhere((book) => book.id == id);
      if (index != -1) {
        currentBooks[index] = updatedBook;
        state = AsyncValue.data([...currentBooks]);
      } else {
        throw Exception('Book not found');
      }
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  Future<void> deleteBook(int id) async {
    state = const AsyncValue.loading();
    try {
      await apiService.deleteBook(id);
      final currentBooks = state.value ?? [];
      currentBooks.removeWhere((book) => book.id == id);
      state = AsyncValue.data([...currentBooks]);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}

以下では、先程実装した ApiService を受け取り、ビルドメソッド内で fetchBooks を実行して、その値を返り値としています。
このようにすることで、この BooksNotifierstate には Future<List<Book>> 型の本の一覧のデータが保持されるようになります。

late final ApiService apiService;


Future<List<Book>> build() async {
  apiService = ref.read(apiServiceProvider);
  return await apiService.fetchBooks();
}

以下では本の追加メソッドを呼び出しています。
apiService.addBook(bookData) で本のデータを追加し、追加が完了したら state を更新して、追加した本が含まれるようにしています。

Future<void> addBook(Map<String, dynamic> bookData) async {
  state = const AsyncValue.loading();
  try {
    final newBook = await apiService.addBook(bookData);
    final currentBooks = state.value ?? [];
    state = AsyncValue.data([...currentBooks, newBook]);
  } catch (e, st) {
    state = AsyncValue.error(e, st);
  }
}

そのほかの update, delete メソッドも基本的な流れとしては同様で、更新・削除処理を行った後、state を更新することで、本一覧データを更新するようにしています。

これで Provider の実装は完了です。

5. UIの実装

最後にUIの実装を行います。
基本的には先程実装した BookNotifier に含まれているメソッドをそれぞれの画面で呼び出しているだけなので、補足等はコメントのみにしたいと思います。
実装する画面は以下です。

  1. 本の一覧画面
  2. 本の詳細画面
  3. 本の追加画面
  4. 本の変更画面

1. 本の一覧画面

lib/screens/book_list_page.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:swagger_ruby_flutter_sample/models/book.dart';
import 'package:swagger_ruby_flutter_sample/providers/books_notifier.dart';
import 'package:swagger_ruby_flutter_sample/screens/book_details_page.dart';
import 'add_book_page.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final booksAsyncValue = ref.watch(booksNotifierProvider);  // 本の一覧を AsyncValue で取得

    return Scaffold(
      appBar: AppBar(
        title: const Text('本の一覧'),
      ),
      body: booksAsyncValue.when(  // AsyncValue の状態に応じて表示変更
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Error: $err')),
        data: (books) {
          if (books.isEmpty) {
            return const Center(child: Text('本が見つかりません'));
          }
          return ListView.builder(
            itemCount: books.length,
            itemBuilder: (context, index) {
              final book = books[index];
              return _BookListItem(book: book);
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {  // 本の追加画面へ
          Navigator.push( 
            context,
            MaterialPageRoute(builder: (context) => const AddBookPage()),
          );
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

class _BookListItem extends StatelessWidget {
  const _BookListItem({required this.book});
  final Book book;

  
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
      child: ListTile(
        leading: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: Image.network(  // 本の書影
            book.imageUrl,
            width: 50,
            height: 100,
            errorBuilder: (context, error, stackTrace) {
              return const Icon(Icons.broken_image);
            },
          ),
        ),
        title: Text(book.title),  // 本のタイトル
        subtitle: Text(book.publisher),  // 本の出版社
        onTap: () {  // 本の詳細画面へ
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => BookDetailsPage(book: book),
            ),
          );
        },
      ),
    );
  }
}

2. 本の詳細画面

lib/screens/book_details_page.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:swagger_ruby_flutter_sample/providers/books_notifier.dart';
import 'package:swagger_ruby_flutter_sample/screens/edit_book_page.dart';
import '../models/book.dart';

class BookDetailsPage extends HookConsumerWidget {
  const BookDetailsPage({
    super.key,
    required this.book,
  });

  final Book book;  // 一覧画面から本のデータを受け取る

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('本の詳細'),
        actions: [
          IconButton(
            icon: const Icon(
              Icons.edit,
              color: Colors.black54,
            ),
            onPressed: () {  // 本の編集画面へ
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => EditBookPage(book: book),
                ),
              );
            },
          ),
          IconButton(
            icon: const Icon(
              Icons.delete,
              color: Colors.black54,
            ),
            onPressed: () {  // 本を削除して良いか確認するダイアログ表示
              _confirmDelete(context, ref, book.id);
            },
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Align(
                child: Text(
                  book.title,
                  style: const TextStyle(
                      fontSize: 24, fontWeight: FontWeight.bold),
                ),
              ),
              const SizedBox(height: 16),
              Align(
                child: Image.network(
                  book.imageUrl,
                  height: 200,
                  fit: BoxFit.cover,
                  errorBuilder: (context, error, stackTrace) {
                    return const Icon(Icons.broken_image, size: 200);
                  },
                ),
              ),
              const SizedBox(height: 16),
              Row(
                children: [
                  const Icon(
                    Icons.apartment,
                    color: Colors.black54,
                  ),
                    const SizedBox(width: 8),
                  Text(
                    book.publisher,
                    style: const TextStyle(fontSize: 18),
                  ),
                ],
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  const Icon(
                    Icons.book,
                    color: Colors.black54,
                  ),
                  const SizedBox(width: 8),
                  Text(
                    '${book.pageCount}ページ',
                    style: const TextStyle(fontSize: 18),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              Text(book.description),
            ],
          ),
        ),
      ),
    );
  }

  void _confirmDelete(BuildContext context, WidgetRef ref, int bookId) {
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('本の削除'),
          content: const Text('本当に削除しますか?'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('キャンセル'),
            ),
            ElevatedButton(
              onPressed: () async {
                try {  // 本を削除
                  await ref
                      .read(booksNotifierProvider.notifier)
                      .deleteBook(bookId);
                  if (context.mounted) {
                    Navigator.pop(context); // ダイアログを閉じる
                    Navigator.pop(context); // 詳細画面を閉じる
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(
                          content: Text('Book deleted successfully')),
                    );
                  }
                } catch (e) {
                  if (context.mounted) {
                    Navigator.pop(context);
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text('Error: $e')),
                    );
                  }
                }
              },
              child: const Text('削除'),
            ),
          ],
        );
      },
    );
  }
}

3. 本の追加画面

lib/screens/add_book_page.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:swagger_ruby_flutter_sample/providers/books_notifier.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final titleController = TextEditingController();  // 登録したい本のデータを保持するコントローラー
    final publisherController = TextEditingController();
    final descriptionController = TextEditingController();
    final pageCountController = TextEditingController();
    final imageUrlController = TextEditingController();

    return Scaffold(
      appBar: AppBar(
        title: const Text('本の追加'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: SingleChildScrollView(
          child: Column(
            children: [
              TextField(
                controller: titleController,
                decoration: const InputDecoration(labelText: 'タイトル'),
              ),
              TextField(
                controller: publisherController,
                decoration: const InputDecoration(labelText: '出版社'),
              ),
              TextField(
                controller: descriptionController,
                decoration: const InputDecoration(labelText: '説明文'),
                maxLines: 3,
              ),
              TextField(
                controller: pageCountController,
                decoration: const InputDecoration(labelText: 'ページ数'),
                keyboardType: TextInputType.number,
              ),
              TextField(
                controller: imageUrlController,
                decoration: const InputDecoration(labelText: '書影画像URL'),
              ),
              const SizedBox(height: 20),
              ElevatedButton(
                onPressed: () async {
                  final title = titleController.text.trim();
                  final publisher = publisherController.text.trim();
                  final description = descriptionController.text.trim();
                  final pageCount =
                      int.tryParse(pageCountController.text.trim()) ?? 0;
                  final imageUrl = imageUrlController.text.trim();

                  if (title.isEmpty ||
                      publisher.isEmpty ||
                      description.isEmpty ||
                      imageUrl.isEmpty) {
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('All fields are required')),
                    );
                    return;
                  }

                  final bookData = {  // データを整形
                    "title": title,
                    "publisher": publisher,
                    "description": description,
                    "page_count": pageCount,
                    "image_url": imageUrl,
                  };

                  try {  // 本の追加処理
                    await ref
                        .read(booksNotifierProvider.notifier)
                        .addBook(bookData);
                    if (context.mounted) {
                      Navigator.pop(context);
                      ScaffoldMessenger.of(context).showSnackBar(
                        const SnackBar(
                            content: Text('Book added successfully')),
                      );
                    }
                  } catch (e) {
                    if (context.mounted) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text('Error: $e')),
                      );
                    }
                  }
                },
                child: const Text('追加'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

4. 本の変更画面

lib/screens/edit_book_page.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:swagger_ruby_flutter_sample/providers/books_notifier.dart';
import '../models/book.dart';

class EditBookPage extends HookConsumerWidget {
  const EditBookPage({
    super.key,
    required this.book,
  });

  final Book book;  // 前のページから変更したい本のデータを受け取る

  
  Widget build(BuildContext context, WidgetRef ref) {
    final titleController = TextEditingController(text: book.title);  // 受け取ったデータをそれぞれ初期値にセット
    final publisherController = TextEditingController(text: book.publisher);
    final descriptionController = TextEditingController(text: book.description);
    final pageCountController =
        TextEditingController(text: book.pageCount.toString());
    final imageUrlController = TextEditingController(text: book.imageUrl);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Edit Book'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: SingleChildScrollView(
          child: Column(
            children: [
              TextField(
                controller: titleController,
                decoration: const InputDecoration(labelText: 'Title'),
              ),
              TextField(
                controller: publisherController,
                decoration: const InputDecoration(labelText: 'Publisher'),
              ),
              TextField(
                controller: descriptionController,
                decoration: const InputDecoration(labelText: 'Description'),
                maxLines: 3,
              ),
              TextField(
                controller: pageCountController,
                decoration: const InputDecoration(labelText: 'Page Count'),
                keyboardType: TextInputType.number,
              ),
              TextField(
                controller: imageUrlController,
                decoration: const InputDecoration(labelText: 'Image URL'),
              ),
              const SizedBox(height: 20),
              ElevatedButton(
                onPressed: () async {
                  final title = titleController.text.trim();
                  final publisher = publisherController.text.trim();
                  final description = descriptionController.text.trim();
                  final pageCount =
                      int.tryParse(pageCountController.text.trim()) ?? 0;
                  final imageUrl = imageUrlController.text.trim();

                  if (title.isEmpty ||
                      publisher.isEmpty ||
                      description.isEmpty ||
                      imageUrl.isEmpty) {  // 全ての項目が埋まっているかチェック
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('All fields are required')),
                    );
                    return;
                  }

                  final updatedBookData = {  // データの整形
                    "title": title,
                    "publisher": publisher,
                    "description": description,
                    "page_count": pageCount,
                    "image_url": imageUrl,
                  };

                  try {  // データの更新処理
                    await ref
                        .read(booksNotifierProvider.notifier)
                        .updateBook(book.id, updatedBookData);
                    if (context.mounted) {
                      Navigator.pop(context);
                      ScaffoldMessenger.of(context).showSnackBar(
                        const SnackBar(
                            content: Text('Book updated successfully')),
                      );
                    }
                  } catch (e) {
                    if (context.mounted) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text('Error: $e')),
                      );
                    }
                  }
                },
                child: const Text('Update Book'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

上記のコードで実行すると以下の動画のように本の追加や削除、更新ができるようになるかと思います。

https://youtube.com/shorts/oyb4MkOyK1g

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は Ruby × Flutter で簡単なサンプルアプリを実装してみました。
Ruby 側では Model や Controller をコマンド一つで作成することができました。
もちろん今回はシンプルな実装のみで、最低限動くものを作るだけだったので、今後新たな実装が必要になればその都度まとめて共有できればと思います。

筆者自身 Ruby, Ruby on Rails について初学者の状態なので、誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://zenn.dev/sasan0/articles/7d0d9c5a2f1edb

Discussion