Firebase for Flutter を理解する

1. 始める前に

この Codelab では、Firebase の基本をいくつか学び、Android と iOS 向けの Flutter モバイルアプリを作成します。

前提条件

学習内容

  • Flutter を使用して Android、iOS、ウェブ、macOS でイベントの出欠確認とゲストブックのチャットアプリを構築する方法。
  • Firebase Authentication でユーザーを認証し、Firestore でデータを同期する方法。

Android 版アプリのホーム画面

iOS 版アプリのホーム画面

必要なもの

次のいずれかのデバイス:

  • パソコンに接続され、デベロッパー モードに設定された物理デバイス(Android または iOS)。
  • iOS シミュレータ(Xcode ツールが必要)。
  • Android Emulator(Android Studio でのセットアップが必要)。

また、次のものも必要です。

  • 任意のブラウザ(Google Chrome など)。
  • Dart プラグインと Flutter プラグインで構成された任意の IDE またはテキスト エディタ(Android StudioVisual Studio Code など)。
  • Flutter の最新の stable バージョン。または、最先端の技術を試したい場合は beta
  • Firebase プロジェクトの作成と管理に使用する Google アカウント。
  • Google アカウントにログインした Firebase CLI

2. サンプルコードを取得する

GitHub からプロジェクトの初期バージョンをダウンロードします。

  1. コマンドラインから、flutter-codelabs ディレクトリに GitHub リポジトリのクローンを作成します。
git clone https://github.com/flutter/codelabs.git flutter-codelabs

flutter-codelabs ディレクトリには、一連の Codelab のコードが含まれています。この Codelab のコードは flutter-codelabs/firebase-get-to-know-flutter ディレクトリにあります。このディレクトリには、各ステップの終了時にプロジェクトがどのように表示されるかを示す一連のスナップショットが含まれています。たとえば、手順 2 に進みます。

  1. 次の手順で一致するファイルを見つけます。
cd flutter-codelabs/firebase-get-to-know-flutter/step_02

早送りしたり、ステップ後の状態を確認したりする場合は、目的のステップの名前が付いたディレクトリをご覧ください。

スターター アプリをインポートする

  • お好みの IDE で flutter-codelabs/firebase-get-to-know-flutter/step_02 ディレクトリを開くか、インポートします。このディレクトリには、Codelab のスターター コードが格納されています。コードは、まだ機能しない Flutter ミートアップ アプリで構成されています。

作業が必要なファイルを見つける

このアプリのコードは複数のディレクトリに分散されています。この機能の分割により、コードが機能ごとにグループ化されるため、作業が容易になります。

  • 次のファイルを見つけます。
    • lib/main.dart: このファイルには、メイン エントリ ポイントとアプリ ウィジェットが含まれています。
    • lib/home_page.dart: このファイルにはホームページ ウィジェットが含まれています。
    • lib/src/widgets.dart: このファイルには、アプリのスタイルを標準化するのに役立つ少数のウィジェットが含まれています。これらはスターター アプリの画面を構成します。
    • lib/src/authentication.dart: このファイルには、Firebase のメールベースの認証用のログイン ユーザー エクスペリエンスを作成する一連のウィジェットを含む、認証の部分的な実装が含まれています。認証フロー用のこれらのウィジェットは、まだスターター アプリで使用されていませんが、まもなく追加します。

必要に応じて追加のファイルを追加して、アプリの残りの部分をビルドします。

lib/main.dart ファイルを確認する

このアプリでは、google_fonts パッケージを利用して、アプリ全体で Roboto をデフォルトのフォントにしています。fonts.google.com でフォントを探し、アプリのさまざまな部分で使用できます。

lib/src/widgets.dart ファイルのヘルパー ウィジェットは、HeaderParagraphIconAndDetail の形式で使用します。これらのウィジェットは、重複したコードを排除し、HomePage で説明されているページ レイアウトの煩雑さを軽減します。これにより、一貫したルック アンド フィールも実現できます。

Android、iOS、ウェブ、macOS でのアプリの表示は次のようになります。

Android 版アプリのホーム画面

iOS 版アプリのホーム画面

ウェブ版アプリのホーム画面

macOS 版アプリのホーム画面

3. Firebase プロジェクトを作成して設定する

イベント情報の表示はゲストにとって便利ですが、単独ではあまり役に立ちません。アプリに動的な機能を追加する必要があります。そのためには、Firebase をアプリに接続する必要があります。Firebase の利用を開始するには、Firebase プロジェクトを作成して設定する必要があります。

Firebase プロジェクトを作成する

  1. Google アカウントを使用して Firebase コンソールにログインします。
  2. ボタンをクリックして新しいプロジェクトを作成し、プロジェクト名(例: Firebase-Flutter-Codelab)を入力します。
  3. [続行] をクリックします。
  4. Firebase の利用規約が表示されたら、内容を読み、同意して [続行] をクリックします。
  5. (省略可)Firebase コンソールで AI アシスタンス(「Gemini in Firebase」)を有効にします。
  6. この Codelab では Google アナリティクスは必要ないため、Google アナリティクスのオプションをオフに切り替えます
  7. [プロジェクトを作成] をクリックし、プロジェクトのプロビジョニングが完了するまで待ってから、[続行] をクリックします。

Firebase プロジェクトの詳細については、Firebase プロジェクトについて理解するをご覧ください。

Firebase プロダクトを設定する

このアプリでは、ウェブアプリで使用できる次の Firebase プロダクトを使用します。

  • Authentication: ユーザーがアプリにログインできるようにします。
  • Firestore: 構造化されたデータをクラウドに保存し、データが変更されたときに即座に通知を受け取ります。
  • Firebase セキュリティ ルール: データベースを保護します。

この中には、特別な設定が必要になるプロダクトや、Firebase コンソールで有効化する必要があるプロダクトがあります。

メールアドレスによるログイン認証を有効にする

  1. Firebase コンソールの [プロジェクトの概要] ペインで、[ビルド] メニューを開きます。
  2. [Authentication > 開始 > ログイン方法 > メールアドレス/パスワード > 有効にする > 保存] をクリックします。

58e3e3e23c2f16a4.png

Firestore を設定する

このウェブアプリは Firestore を使用してチャット メッセージを保存し、新しいチャット メッセージを受信します。

Firebase プロジェクトで Firestore を設定する方法は次のとおりです。

  1. Firebase コンソールの左側のパネルで [ビルド] を展開し、[Firestore データベース] を選択します。
  2. [データベースを作成] をクリックします。
  3. [データベース ID] は (default) に設定したままにします。
  4. データベースの場所を選択し、[次へ] をクリックします。
    実際のアプリでは、ユーザーに近い場所を選択します。
  5. [テストモードで開始] をクリックします。セキュリティ ルールに関する免責条項を確認します。
    この Codelab の後半で、データを保護するためのセキュリティ ルールを追加します。データベースのセキュリティ ルールを追加せずに、アプリを配布または公開しないでください。
  6. [作成] をクリックします。

4. Firebase を構成する

Flutter で Firebase を使用するには、次のタスクを完了して、FlutterFire ライブラリを正しく使用するように Flutter プロジェクトを構成する必要があります。

  1. プロジェクトに FlutterFire 依存関係を追加します。
  2. 目的のプラットフォームを Firebase プロジェクトに登録します。
  3. プラットフォーム固有の構成ファイルをダウンロードしてコードに追加する。

Flutter アプリの最上位ディレクトリには、androidiosmacosweb というサブディレクトリがあり、それぞれに iOS と Android のプラットフォーム固有の構成ファイルが格納されています。

依存関係を構成

このアプリで使用する 2 つの Firebase プロダクト(Authentication と Firestore)の FlutterFire ライブラリを追加する必要があります。

  • コマンドラインから次の依存関係を追加します。
$ flutter pub add firebase_core

firebase_core パッケージは、すべての Firebase Flutter プラグインに必要な共通コードです。

$ flutter pub add firebase_auth

firebase_auth パッケージを使用すると、Authentication との統合が可能になります。

$ flutter pub add cloud_firestore

cloud_firestore パッケージを使用すると、Firestore データ ストレージにアクセスできます。

$ flutter pub add provider

firebase_ui_auth パッケージは、認証フローで開発者のベロシティを高めるための一連のウィジェットとユーティリティを提供します。

$ flutter pub add firebase_ui_auth

必要なパッケージを追加しましたが、Firebase を適切に使用するように iOS、Android、macOS、ウェブのランナー プロジェクトも構成する必要があります。また、ビジネス ロジックと表示ロジックを分離できる provider パッケージも使用します。

FlutterFire CLI をインストールする

FlutterFire CLI は、基盤となる Firebase CLI に依存しています。

  1. まだインストールしていない場合は、マシンに Firebase CLI をインストールします。
  2. FlutterFire CLI をインストールします。
$ dart pub global activate flutterfire_cli

インストールすると、flutterfire コマンドがグローバルに使用できるようになります。

アプリを構成する

CLI は、Firebase プロジェクトと選択したプロジェクト アプリから情報を抽出し、特定のプラットフォームのすべての構成を生成します。

アプリのルートで、configure コマンドを実行します。

$ flutterfire configure

構成コマンドは、次のプロセスをガイドします。

  1. .firebaserc ファイルまたは Firebase コンソールに基づいて Firebase プロジェクトを選択します。
  2. 構成のプラットフォーム(Android、iOS、macOS、ウェブなど)を特定します。
  3. 構成を抽出する Firebase アプリを特定します。デフォルトでは、CLI は現在のプロジェクト構成に基づいて Firebase アプリの自動マッチングを試みます。
  4. プロジェクトに firebase_options.dart ファイルを生成します。

macOS を構成する

macOS 版の Flutter は、完全にサンドボックス化されたアプリをビルドします。このアプリはネットワークと統合して Firebase サーバーと通信するため、ネットワーク クライアント権限でアプリを構成する必要があります。

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.network.server</key>
	<true/>
  <!-- Add the following two lines -->
	<key>com.apple.security.network.client</key>
	<true/>
</dict>
</plist>

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
  <!-- Add the following two lines -->
	<key>com.apple.security.network.client</key>
	<true/>
</dict>
</plist>

詳細については、Flutter のデスクトップ サポートをご覧ください。

5. 出欠確認機能を追加する

アプリに Firebase を追加したので、Authentication にユーザーを登録する [RSVP] ボタンを作成できます。Android ネイティブ、iOS ネイティブ、ウェブには、事前構築済みの FirebaseUI Auth パッケージがありますが、Flutter ではこの機能を構築する必要があります。

先ほど取得したプロジェクトには、ほとんどの認証フローのユーザー インターフェースを実装する一連のウィジェットが含まれています。ビジネス ロジックを実装して、Authentication をアプリと統合します。

Provider パッケージを使用してビジネス ロジックを追加する

provider パッケージを使用して、アプリの Flutter ウィジェットのツリー全体で一元化されたアプリの状態オブジェクトを利用できるようにします。

  1. 次の内容のファイルを app_state.dart という名前で新規に作成します。

lib/app_state.dart

import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ui_auth/firebase_ui_auth.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

class ApplicationState extends ChangeNotifier {
  ApplicationState() {
    init();
  }

  bool _loggedIn = false;
  bool get loggedIn => _loggedIn;

  Future<void> init() async {
    await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform);

    FirebaseUIAuth.configureProviders([
      EmailAuthProvider(),
    ]);

    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loggedIn = true;
      } else {
        _loggedIn = false;
      }
      notifyListeners();
    });
  }
}

import ステートメントは Firebase Core と Auth を導入し、ウィジェット ツリー全体でアプリの状態オブジェクトを使用可能にする provider パッケージを取り込み、firebase_ui_auth パッケージから認証ウィジェットを含めます。

この ApplicationState アプリケーション状態オブジェクトには、このステップで 1 つの主な役割があります。それは、認証状態が更新されたことをウィジェット ツリーに通知することです。

プロバイダは、ユーザーのログイン ステータスの状態をアプリに伝えるためにのみ使用します。ユーザーがログインできるようにするには、firebase_ui_auth パッケージで提供される UI を使用します。これは、アプリでログイン画面をすばやくブートストラップする優れた方法です。

認証フローを統合する

  1. lib/main.dart ファイルの先頭にあるインポートを変更します。

lib/main.dart

import 'package:firebase_ui_auth/firebase_ui_auth.dart'; // new
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';               // new
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';                 // new

import 'app_state.dart';                                 // new
import 'home_page.dart';
  1. アプリの状態をアプリの初期化に関連付け、認証フローを HomePage に追加します。

lib/main.dart

void main() {
  // Modify from here...
  WidgetsFlutterBinding.ensureInitialized();

  runApp(ChangeNotifierProvider(
    create: (context) => ApplicationState(),
    builder: ((context, child) => const App()),
  ));
  // ...to here.
}

main() 関数の変更により、プロバイダ パッケージが ChangeNotifierProvider ウィジェットによるアプリ状態オブジェクトのインスタンス化を担当するようになります。この特定の provider クラスを使用するのは、アプリの状態オブジェクトが ChangeNotifier クラスを拡張するためです。これにより、provider パッケージは依存ウィジェットを再表示するタイミングを把握できます。

  1. GoRouter 構成を作成して、FirebaseUI が提供するさまざまな画面へのナビゲーションを処理するようにアプリを更新します。

lib/main.dart

// Add GoRouter configuration outside the App class
final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
      routes: [
        GoRoute(
          path: 'sign-in',
          builder: (context, state) {
            return SignInScreen(
              actions: [
                ForgotPasswordAction(((context, email) {
                  final uri = Uri(
                    path: '/sign-in/forgot-password',
                    queryParameters: <String, String?>{
                      'email': email,
                    },
                  );
                  context.push(uri.toString());
                })),
                AuthStateChangeAction(((context, state) {
                  final user = switch (state) {
                    SignedIn state => state.user,
                    UserCreated state => state.credential.user,
                    _ => null
                  };
                  if (user == null) {
                    return;
                  }
                  if (state is UserCreated) {
                    user.updateDisplayName(user.email!.split('@')[0]);
                  }
                  if (!user.emailVerified) {
                    user.sendEmailVerification();
                    const snackBar = SnackBar(
                        content: Text(
                            'Please check your email to verify your email address'));
                    ScaffoldMessenger.of(context).showSnackBar(snackBar);
                  }
                  context.pushReplacement('/');
                })),
              ],
            );
          },
          routes: [
            GoRoute(
              path: 'forgot-password',
              builder: (context, state) {
                final arguments = state.uri.queryParameters;
                return ForgotPasswordScreen(
                  email: arguments['email'],
                  headerMaxExtent: 200,
                );
              },
            ),
          ],
        ),
        GoRoute(
          path: 'profile',
          builder: (context, state) {
            return ProfileScreen(
              providers: const [],
              actions: [
                SignedOutAction((context) {
                  context.pushReplacement('/');
                }),
              ],
            );
          },
        ),
      ],
    ),
  ],
);
// end of GoRouter configuration

// Change MaterialApp to MaterialApp.router and add the routerConfig
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Firebase Meetup',
      theme: ThemeData(
        buttonTheme: Theme.of(context).buttonTheme.copyWith(
              highlightColor: Colors.deepPurple,
            ),
        primarySwatch: Colors.deepPurple,
        textTheme: GoogleFonts.robotoTextTheme(
          Theme.of(context).textTheme,
        ),
        visualDensity: VisualDensity.adaptivePlatformDensity,
        useMaterial3: true,
      ),
      routerConfig: _router, // new
    );
  }
}

各画面には、認証フローの新しい状態に基づいて、異なるタイプのアクションが関連付けられています。認証のほとんどの状態変化の後、ホーム画面やプロフィールなどの別の画面など、優先する画面にリダイレクトできます。

  1. HomePage クラスの build メソッドで、アプリの状態を AuthFunc ウィジェットと統合します。

lib/home_page.dart

import 'package:firebase_auth/firebase_auth.dart' // new
    hide EmailAuthProvider, PhoneAuthProvider;    // new
import 'package:flutter/material.dart';           // new
import 'package:provider/provider.dart';          // new

import 'app_state.dart';                          // new
import 'src/authentication.dart';                 // new
import 'src/widgets.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Firebase Meetup'),
      ),
      body: ListView(
        children: <Widget>[
          Image.asset('assets/codelab.png'),
          const SizedBox(height: 8),
          const IconAndDetail(Icons.calendar_today, 'October 30'),
          const IconAndDetail(Icons.location_city, 'San Francisco'),
          // Add from here
          Consumer<ApplicationState>(
            builder: (context, appState, _) => AuthFunc(
                loggedIn: appState.loggedIn,
                signOut: () {
                  FirebaseAuth.instance.signOut();
                }),
          ),
          // to here
          const Divider(
            height: 8,
            thickness: 1,
            indent: 8,
            endIndent: 8,
            color: Colors.grey,
          ),
          const Header("What we'll be doing"),
          const Paragraph(
            'Join us for a day full of Firebase Workshops and Pizza!',
          ),
        ],
      ),
    );
  }
}

AuthFunc ウィジェットをインスタンス化し、Consumer ウィジェットでラップします。Consumer ウィジェットは、アプリの状態が変化したときにツリーの一部を再構築するために provider パッケージを使用する一般的な方法です。AuthFunc ウィジェットは、テストする補足ウィジェットです。

認証フローをテストする

cdf2d25e436bd48d.png

  1. アプリで [出欠確認] ボタンをタップして SignInScreen を開始します。

2a2cd6d69d172369.png

  1. メールアドレスを入力します。すでに登録済みの場合は、パスワードの入力を求めるメッセージが表示されます。それ以外の場合は、登録フォームの入力が求められます。

e5e65065dba36b54.png

  1. 6 文字未満のパスワードを入力して、エラー処理フローを確認します。登録済みの場合は、代わりにパスワードが表示されます。
  2. 間違ったパスワードを入力して、エラー処理フローを確認します。
  3. 正しいパスワードを入力します。ログイン エクスペリエンスが表示され、ユーザーはログアウトできます。

4ed811a25b0cf816.png

6. Firestore にメッセージを書き込む

ユーザーが訪れるのはよいことですが、ゲストがアプリ内でできることを増やす必要があります。ゲストブックにメッセージを残せるようにするのはどうでしょうか。参加を楽しみにしている理由や、会いたい相手を共有できます。

ユーザーがアプリで作成したチャット メッセージを保存するには、Firestore を使用します。

データモデル

Firestore は NoSQL データベースであり、データベースに保存されたデータはコレクション、ドキュメント、フィールド、サブコレクションに分割されます。チャットの各メッセージは、最上位のコレクションである guestbook コレクションにドキュメントとして保存されます。

7c20dc8424bb1d84.png

メッセージを Firestore に追加する

このセクションでは、ユーザーがデータベースにメッセージを書き込む機能を追加します。まず、フォーム フィールドと送信ボタンを追加し、次に、これらの要素をデータベースに接続するコードを追加します。

  1. guest_book.dart という名前の新しいファイルを作成し、GuestBook ステートフル ウィジェットを追加して、メッセージ フィールドと送信ボタンの UI 要素を構築します。

lib/guest_book.dart

import 'dart:async';

import 'package:flutter/material.dart';

import 'src/widgets.dart';

class GuestBook extends StatefulWidget {
  const GuestBook({required this.addMessage, super.key});

  final FutureOr<void> Function(String message) addMessage;

  @override
  State<GuestBook> createState() => _GuestBookState();
}

class _GuestBookState extends State<GuestBook> {
  final _formKey = GlobalKey<FormState>(debugLabel: '_GuestBookState');
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Form(
        key: _formKey,
        child: Row(
          children: [
            Expanded(
              child: TextFormField(
                controller: _controller,
                decoration: const InputDecoration(
                  hintText: 'Leave a message',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Enter your message to continue';
                  }
                  return null;
                },
              ),
            ),
            const SizedBox(width: 8),
            StyledButton(
              onPressed: () async {
                if (_formKey.currentState!.validate()) {
                  await widget.addMessage(_controller.text);
                  _controller.clear();
                }
              },
              child: Row(
                children: const [
                  Icon(Icons.send),
                  SizedBox(width: 4),
                  Text('SEND'),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

ここで注目すべき点がいくつかあります。まず、フォームをインスタンス化して、メッセージに実際にコンテンツが含まれていることを検証し、コンテンツがない場合はユーザーにエラー メッセージを表示できるようにします。フォームを検証するには、GlobalKey を使用してフォームの背後にあるフォームの状態にアクセスします。キーとその使用方法の詳細については、キーを使用するタイミングをご覧ください。

また、ウィジェットのレイアウトにも注目してください。TextFormFieldStyledButton を含む Row があり、StyledButton には Row が含まれています。また、TextFormFieldExpanded ウィジェットでラップされており、これにより TextFormField は行の余分なスペースを埋めるようになっています。この要件の理由については、制約についてをご覧ください。

ユーザーがテキストを入力してゲストブックに追加できるウィジェットができたので、これを画面に表示する必要があります。

  1. HomePage の本文を編集して、ListView の子の末尾に次の 2 行を追加します。
const Header("What we'll be doing"),
const Paragraph(
  'Join us for a day full of Firebase Workshops and Pizza!',
),
// Add the following two lines.
const Header('Discussion'),
GuestBook(addMessage: (message) => print(message)),

ウィジェットを表示するにはこれで十分ですが、有用な処理を行うには不十分です。このコードは、すぐに更新して機能させます。

アプリのプレビュー

チャット統合が有効になっている Android 版アプリのホーム画面

チャット統合機能が搭載された iOS 版アプリのホーム画面

チャット統合機能が搭載されたウェブ版アプリのホーム画面

チャット統合が完了した macOS 版アプリのホーム画面

ユーザーが [送信] をクリックすると、次のコード スニペットがトリガーされます。メッセージ入力フィールドの内容がデータベースの guestbook コレクションに追加されます。具体的には、addMessageToGuestBook メソッドは、メッセージ コンテンツを guestbook コレクション内の自動生成された ID を持つ新しいドキュメントに追加します。

FirebaseAuth.instance.currentUser.uid は、ログインしているすべてのユーザーに対して Authentication が提供する自動生成された一意の ID への参照です。

  • lib/app_state.dart ファイルに addMessageToGuestBook メソッドを追加します。次のステップでは、この機能をユーザー インターフェースに接続します。

lib/app_state.dart

import 'package:cloud_firestore/cloud_firestore.dart'; // new
import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ui_auth/firebase_ui_auth.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

class ApplicationState extends ChangeNotifier {

  // Current content of ApplicationState elided ...

  // Add from here...
  Future<DocumentReference> addMessageToGuestBook(String message) {
    if (!_loggedIn) {
      throw Exception('Must be logged in');
    }

    return FirebaseFirestore.instance
        .collection('guestbook')
        .add(<String, dynamic>{
      'text': message,
      'timestamp': DateTime.now().millisecondsSinceEpoch,
      'name': FirebaseAuth.instance.currentUser!.displayName,
      'userId': FirebaseAuth.instance.currentUser!.uid,
    });
  }
  // ...to here.
}

UI とデータベースを接続する

ユーザーがゲストブックに追加するテキストを入力できる UI と、エントリを Firestore に追加するコードがあります。あとは、この 2 つを接続するだけです。

  • lib/home_page.dart ファイルで、HomePage ウィジェットを次のように変更します。

lib/home_page.dart

import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';
import 'guest_book.dart';                         // new
import 'src/authentication.dart';
import 'src/widgets.dart';

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Firebase Meetup'),
      ),
      body: ListView(
        children: <Widget>[
          Image.asset('assets/codelab.png'),
          const SizedBox(height: 8),
          const IconAndDetail(Icons.calendar_today, 'October 30'),
          const IconAndDetail(Icons.location_city, 'San Francisco'),
          Consumer<ApplicationState>(
            builder: (context, appState, _) => AuthFunc(
                loggedIn: appState.loggedIn,
                signOut: () {
                  FirebaseAuth.instance.signOut();
                }),
          ),
          const Divider(
            height: 8,
            thickness: 1,
            indent: 8,
            endIndent: 8,
            color: Colors.grey,
          ),
          const Header("What we'll be doing"),
          const Paragraph(
            'Join us for a day full of Firebase Workshops and Pizza!',
          ),
          // Modify from here...
          Consumer<ApplicationState>(
            builder: (context, appState, _) => Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (appState.loggedIn) ...[
                  const Header('Discussion'),
                  GuestBook(
                    addMessage: (message) =>
                        appState.addMessageToGuestBook(message),
                  ),
                ],
              ],
            ),
          ),
          // ...to here.
        ],
      ),
    );
  }
}

この手順の冒頭で追加した 2 行を、完全な実装に置き換えました。ここでも Consumer<ApplicationState> を使用して、レンダリングするツリーの部分でアプリの状態を利用できるようにします。これにより、UI でメッセージを入力したユーザーに反応し、データベースに公開できます。次のセクションでは、追加したメッセージがデータベースに公開されているかどうかをテストします。

メッセージの送信をテストする

  1. 必要に応じてアプリにログインします。
  2. Hey there! などのメッセージを入力し、[送信] をクリックします。

このアクションは、メッセージを Firestore データベースに書き込みます。ただし、実際の Flutter アプリにはメッセージが表示されません。これは、次のステップでデータの取得を実装する必要があるためです。ただし、Firebase コンソールの [Database] ダッシュボードでは、追加したメッセージが guestbook コレクションに表示されます。メッセージを送信するたびに、guestbook コレクションにドキュメントが追加されます。たとえば、次のコード スニペットをご覧ください。

713870af0b3b63c.png

7. メッセージを読む

ゲストがデータベースにメッセージを書き込めるのは素晴らしいことですが、まだアプリでメッセージを表示することはできません。これを修正しましょう。

メッセージを同期する

メッセージを表示するには、データが変更されたときにトリガーするリスナーを追加し、新しいメッセージを表示する UI 要素を作成する必要があります。アプリから新しく追加されたメッセージをリッスンするコードをアプリの状態に追加します。

  1. 新しいファイル guest_book_message.dart を作成し、次のクラスを追加して、Firestore に保存したデータの構造化されたビューを公開します。

lib/guest_book_message.dart

class GuestBookMessage {
  GuestBookMessage({required this.name, required this.message});

  final String name;
  final String message;
}
  1. lib/app_state.dart ファイルに次のインポートを追加します。

lib/app_state.dart

import 'dart:async';                                     // new

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ui_auth/firebase_ui_auth.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';
import 'guest_book_message.dart';                        // new
  1. 状態とゲッターを定義する ApplicationState セクションに、次の行を追加します。

lib/app_state.dart

  bool _loggedIn = false;
  bool get loggedIn => _loggedIn;

  // Add from here...
  StreamSubscription<QuerySnapshot>? _guestBookSubscription;
  List<GuestBookMessage> _guestBookMessages = [];
  List<GuestBookMessage> get guestBookMessages => _guestBookMessages;
  // ...to here.
  1. ApplicationState の初期化セクションに次の行を追加して、ユーザーがログインしたときにドキュメント コレクションに対するクエリを登録し、ログアウトしたときに登録を解除します。

lib/app_state.dart

  Future<void> init() async {
    await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform);

    FirebaseUIAuth.configureProviders([
      EmailAuthProvider(),
    ]);
    
    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loggedIn = true;
        _guestBookSubscription = FirebaseFirestore.instance
            .collection('guestbook')
            .orderBy('timestamp', descending: true)
            .snapshots()
            .listen((snapshot) {
          _guestBookMessages = [];
          for (final document in snapshot.docs) {
            _guestBookMessages.add(
              GuestBookMessage(
                name: document.data()['name'] as String,
                message: document.data()['text'] as String,
              ),
            );
          }
          notifyListeners();
        });
      } else {
        _loggedIn = false;
        _guestBookMessages = [];
        _guestBookSubscription?.cancel();
      }
      notifyListeners();
    });
  }

このセクションは、guestbook コレクションに対するクエリを構築し、このコレクションへの登録と登録解除を処理する場所であるため、重要です。ストリームをリッスンし、guestbook コレクション内のメッセージのローカル キャッシュを再構築します。また、このサブスクリプションへの参照を保存して、後でサブスクリプションを解除できるようにします。ここでは多くのことが行われているため、デバッガで探索して、何が起こっているのかを検査し、より明確なメンタルモデルを取得する必要があります。詳細については、Firestore でリアルタイム アップデートを入手するをご覧ください。

  1. lib/guest_book.dart ファイルに次の import を追加します。
import 'guest_book_message.dart';
  1. GuestBook ウィジェットで、構成の一部としてメッセージのリストを追加し、この変化する状態をユーザー インターフェースに接続します。

lib/guest_book.dart

class GuestBook extends StatefulWidget {
  // Modify the following line:
  const GuestBook({
    super.key, 
    required this.addMessage, 
    required this.messages,
  });

  final FutureOr<void> Function(String message) addMessage;
  final List<GuestBookMessage> messages; // new

  @override
  _GuestBookState createState() => _GuestBookState();
}
  1. _GuestBookState で、build メソッドを次のように変更して、この構成を公開します。

lib/guest_book.dart

class _GuestBookState extends State<GuestBook> {
  final _formKey = GlobalKey<FormState>(debugLabel: '_GuestBookState');
  final _controller = TextEditingController();

  @override
  // Modify from here...
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // ...to here.
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Form(
            key: _formKey,
            child: Row(
              children: [
                Expanded(
                  child: TextFormField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: 'Leave a message',
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Enter your message to continue';
                      }
                      return null;
                    },
                  ),
                ),
                const SizedBox(width: 8),
                StyledButton(
                  onPressed: () async {
                    if (_formKey.currentState!.validate()) {
                      await widget.addMessage(_controller.text);
                      _controller.clear();
                    }
                  },
                  child: Row(
                    children: const [
                      Icon(Icons.send),
                      SizedBox(width: 4),
                      Text('SEND'),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
        // Modify from here...
        const SizedBox(height: 8),
        for (var message in widget.messages)
          Paragraph('${message.name}: ${message.message}'),
        const SizedBox(height: 8),
      ],
      // ...to here.
    );
  }
}

build() メソッドの以前のコンテンツを Column ウィジェットでラップし、Column の子要素の末尾に collection for を追加して、メッセージ リスト内の各メッセージに対して新しい Paragraph を生成します。

  1. 新しい messages パラメータを使用して GuestBook を正しく構築するように HomePage の本文を更新します。

lib/home_page.dart

Consumer<ApplicationState>(
  builder: (context, appState, _) => Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      if (appState.loggedIn) ...[
        const Header('Discussion'),
        GuestBook(
          addMessage: (message) =>
              appState.addMessageToGuestBook(message),
          messages: appState.guestBookMessages, // new
        ),
      ],
    ],
  ),
),

メッセージの同期をテストする

Firestore は、データベースに登録されているクライアントとデータを自動的かつ瞬時に同期します。

メッセージの同期をテストする:

  1. アプリで、データベースに以前作成したメッセージを見つけます。
  2. 新しいメッセージを作成する。すぐに表示されます。
  3. ワークスペースを複数のウィンドウまたはタブで開きます。メッセージはウィンドウとタブ間でリアルタイムに同期されます。
  4. 省略可: Firebase コンソールの [Database] メニューで、メッセージを手動で削除、変更、追加します。すべての変更が UI に表示されます。

これで完了です。アプリで Firestore ドキュメントを読み取ることができるようになりました。

アプリのプレビュー

チャット統合が有効になっている Android 版アプリのホーム画面

チャット統合機能が搭載された iOS 版アプリのホーム画面

チャット統合機能が搭載されたウェブ版アプリのホーム画面

チャット統合が完了した macOS 版アプリのホーム画面

8. 基本的なセキュリティ ルールを設定する

お客様は、テストモードを使用するように Firestore を初期設定しました。つまり、データベースは読み取りと書き込みが可能な状態になっています。ただし、テストモードは開発の初期段階でのみ使用してください。ベスト プラクティスとして、アプリの開発時にデータベースのセキュリティ ルールを設定する必要があります。セキュリティはアプリの構造と動作に不可欠です。

Firebase セキュリティ ルールを使用すると、データベース内のドキュメントとコレクションへのアクセスを制御できます。ルールの構文は柔軟なので、データベース全体に対するすべての書き込みオペレーションから特定のドキュメントに対するオペレーションまで、あらゆるオペレーションに適格なルールを作成できます。

基本的なセキュリティ ルールを設定します。

  1. Firebase コンソールの [開発] メニューで、[データベース > ルール] をクリックします。次のデフォルトのセキュリティ ルールと、ルールが公開されていることに関する警告が表示されます。

7767a2d2e64e7275.png

  1. アプリがデータを書き込むコレクションを特定します。

match /databases/{database}/documents で、保護するコレクションを特定します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /guestbook/{entry} {
     // You'll add rules here in the next step.
  }
}

各ゲストブック ドキュメントのフィールドとして Authentication UID を使用したため、Authentication UID を取得して、ドキュメントへの書き込みを試みるユーザーが一致する Authentication UID を持っていることを確認できます。

  1. 読み取りルールと書き込みルールをルールセットに追加します。
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /guestbook/{entry} {
      allow read: if request.auth.uid != null;
      allow write:
        if request.auth.uid == request.resource.data.userId;
    }
  }
}

現在、ゲストブックのメッセージを閲覧できるのはログイン ユーザーのみですが、メッセージを編集できるのはメッセージの作成者のみです。

  1. データ検証を追加して、ドキュメントに想定されるすべてのフィールドが存在することを確認します。
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /guestbook/{entry} {
      allow read: if request.auth.uid != null;
      allow write:
      if request.auth.uid == request.resource.data.userId
          && "name" in request.resource.data
          && "text" in request.resource.data
          && "timestamp" in request.resource.data;
    }
  }
}

9. ボーナス ステップ: 学習した内容を実践する

参加者の出欠確認のステータスを記録する

現在、アプリではイベントに関心のあるユーザーのみがチャットできます。また、誰かが参加しようとしていることを知る唯一の方法は、チャットでその旨を伝えることです。

このステップでは、参加人数を把握し、参加者に人数を伝えます。アプリの状態にいくつかの機能を追加します。1 つ目は、ログインしているユーザーが出席するかどうかを指定できることです。2 つ目は、参加者の数をカウントするものです。

  1. lib/app_state.dart ファイルの ApplicationState のアクセサー セクションに次の行を追加して、UI コードがこの状態とやり取りできるようにします。

lib/app_state.dart

int _attendees = 0;
int get attendees => _attendees;

Attending _attending = Attending.unknown;
StreamSubscription<DocumentSnapshot>? _attendingSubscription;
Attending get attending => _attending;
set attending(Attending attending) {
  final userDoc = FirebaseFirestore.instance
      .collection('attendees')
      .doc(FirebaseAuth.instance.currentUser!.uid);
  if (attending == Attending.yes) {
    userDoc.set(<String, dynamic>{'attending': true});
  } else {
    userDoc.set(<String, dynamic>{'attending': false});
  }
}
  1. ApplicationStateinit() メソッドを次のように更新します。

lib/app_state.dart

  Future<void> init() async {
    await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform);

    FirebaseUIAuth.configureProviders([
      EmailAuthProvider(),
    ]);

    // Add from here...
    FirebaseFirestore.instance
        .collection('attendees')
        .where('attending', isEqualTo: true)
        .snapshots()
        .listen((snapshot) {
      _attendees = snapshot.docs.length;
      notifyListeners();
    });
    // ...to here.

    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loggedIn = true;
        _emailVerified = user.emailVerified;
        _guestBookSubscription = FirebaseFirestore.instance
            .collection('guestbook')
            .orderBy('timestamp', descending: true)
            .snapshots()
            .listen((snapshot) {
          _guestBookMessages = [];
          for (final document in snapshot.docs) {
            _guestBookMessages.add(
              GuestBookMessage(
                name: document.data()['name'] as String,
                message: document.data()['text'] as String,
              ),
            );
          }
          notifyListeners();
        });
        // Add from here...
        _attendingSubscription = FirebaseFirestore.instance
            .collection('attendees')
            .doc(user.uid)
            .snapshots()
            .listen((snapshot) {
          if (snapshot.data() != null) {
            if (snapshot.data()!['attending'] as bool) {
              _attending = Attending.yes;
            } else {
              _attending = Attending.no;
            }
          } else {
            _attending = Attending.unknown;
          }
          notifyListeners();
        });
        // ...to here.
      } else {
        _loggedIn = false;
        _emailVerified = false;
        _guestBookMessages = [];
        _guestBookSubscription?.cancel();
        _attendingSubscription?.cancel(); // new
      }
      notifyListeners();
    });
  }

このコードでは、参加者の数を特定するための常にサブスクライブされるクエリと、ユーザーがログインしている間のみアクティブになる、ユーザーが参加しているかどうかを特定するための 2 つ目のクエリが追加されます。

  1. lib/app_state.dart ファイルの先頭に次の列挙型を追加します。

lib/app_state.dart

enum Attending { yes, no, unknown }
  1. 新しいファイル yes_no_selection.dart を作成し、ラジオボタンのように動作する新しいウィジェットを定義します。

lib/yes_no_selection.dart

import 'package:flutter/material.dart';

import 'app_state.dart';
import 'src/widgets.dart';

class YesNoSelection extends StatelessWidget {
  const YesNoSelection(
      {super.key, required this.state, required this.onSelection});
  final Attending state;
  final void Function(Attending selection) onSelection;

  @override
  Widget build(BuildContext context) {
    switch (state) {
      case Attending.yes:
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              FilledButton(
                onPressed: () => onSelection(Attending.yes),
                child: const Text('YES'),
              ),
              const SizedBox(width: 8),
              TextButton(
                onPressed: () => onSelection(Attending.no),
                child: const Text('NO'),
              ),
            ],
          ),
        );
      case Attending.no:
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              TextButton(
                onPressed: () => onSelection(Attending.yes),
                child: const Text('YES'),
              ),
              const SizedBox(width: 8),
              FilledButton(
                onPressed: () => onSelection(Attending.no),
                child: const Text('NO'),
              ),
            ],
          ),
        );
      default:
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              StyledButton(
                onPressed: () => onSelection(Attending.yes),
                child: const Text('YES'),
              ),
              const SizedBox(width: 8),
              StyledButton(
                onPressed: () => onSelection(Attending.no),
                child: const Text('NO'),
              ),
            ],
          ),
        );
    }
  }
}

最初は [はい] も [いいえ] も選択されていない不定の状態です。ユーザーが参加するかどうかを選択すると、そのオプションが塗りつぶしボタンでハイライト表示され、もう一方のオプションはフラットなレンダリングで表示されます。

  1. YesNoSelection を活用するように HomePagebuild() メソッドを更新し、ログイン ユーザーが参加するかどうかを指定できるようにして、イベントの参加者数を表示します。

lib/home_page.dart

Consumer<ApplicationState>(
  builder: (context, appState, _) => Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      // Add from here...
      switch (appState.attendees) {
        1 => const Paragraph('1 person going'),
        >= 2 => Paragraph('${appState.attendees} people going'),
        _ => const Paragraph('No one going'),
      },
      // ...to here.
      if (appState.loggedIn) ...[
        // Add from here...
        YesNoSelection(
          state: appState.attending,
          onSelection: (attending) => appState.attending = attending,
        ),
        // ...to here.
        const Header('Discussion'),
        GuestBook(
          addMessage: (message) =>
              appState.addMessageToGuestBook(message),
          messages: appState.guestBookMessages,
        ),
      ],
    ],
  ),
),

ルールの追加

すでにルールを設定しているため、ボタンで追加したデータは拒否されます。attendees コレクションへの追加を許可するようにルールを更新する必要があります。

  1. attendees コレクションで、ドキュメント名として使用した認証 UID を取得し、送信者の uid が書き込んでいるドキュメントと同じであることを確認します。
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ... //
    match /attendees/{userId} {
      allow read: if true;
      allow write: if request.auth.uid == userId;
    }
  }
}

この場合、参加者リストには非公開データが含まれていないため、すべてのユーザーがリストを閲覧できますが、更新できるのは作成者のみです。

  1. データ検証を追加して、ドキュメントに想定されるすべてのフィールドが存在することを確認します。
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ... //
    match /attendees/{userId} {
      allow read: if true;
      allow write: if request.auth.uid == userId
          && "attending" in request.resource.data;

    }
  }
}
  1. 省略可: アプリでボタンをクリックして、Firebase コンソールの Firestore ダッシュボードで結果を確認します。

アプリのプレビュー

Android 版アプリのホーム画面

iOS 版アプリのホーム画面

ウェブ版アプリのホーム画面

macOS 版アプリのホーム画面

10. 完了

Firebase を使用して、インタラクティブなリアルタイム ウェブアプリを作成しました。

詳細