Firebase for Flutter を理解する

1. 始める前に

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

前提条件

学習内容

  • Flutter を使用して、Android、iOS、ウェブ、macOS でイベントの RSVP とゲストブックのチャットアプリを作成する方法。
  • 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. コマンドラインから、GitHub リポジトリのクローンを flutter-codelabs ディレクトリに作成します。
git clone https://github.com/flutter/codelabs.git flutter-codelabs

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

  1. 2 番目のステップに一致するファイルを探します。
cd flutter-codelabs/firebase-get-to-know-flutter/step_02

先に進む場合や、手順後の状態を確認したい場合は、該当する手順の名前が付けられたディレクトリを参照してください。

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

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

修正が必要なファイルを探す

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

  • 次のファイルを探します。
    • 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. Firebase にログインします。
  2. コンソールで、[プロジェクトを追加] または [プロジェクトを作成] をクリックします。
  3. [プロジェクト名] フィールドに「Firebase-Flutter-Codelab」と入力し、[続行] をクリックします。

4395e4e67c08043a.png

  1. プロジェクト作成オプションをクリックします。プロンプトが表示されたら、Firebase の利用規約に同意します。ただし、このアプリでは Google アナリティクスを使用しないため、設定はスキップします。

b7138cde5f2c7b61.png

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

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

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

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

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

  1. Firebase コンソールの [プロジェクトの概要] ペインで、[ビルド] メニューを開きます。
  2. [Authentication] > [Get Started] > [Sign-in method] > [Email/Password] > [Enable] > [Save] をクリックします。

58e3e3e23c2f16a4.png

Firestore の設定

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

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

  1. Firebase コンソールの左側のパネルで [Build] を開き、[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 パッケージを使用すると、認証との統合が可能になります。

$ 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 をアプリに追加したので、認証でユーザーを登録する [RSVP] ボタンを作成できます。Android ネイティブ、iOS ネイティブ、ウェブには、事前ビルドされた FirebaseUI Auth パッケージがありますが、Flutter の場合はこの機能をビルドする必要があります。

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

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 ウィジェットでラップします。コンシューマ ウィジェットは、アプリの状態が変化したときに provider パッケージを使用してツリーの一部を再ビルドする一般的な方法です。AuthFunc ウィジェットは、テストする補足ウィジェットです。

認証フローをテストする

cdf2d25e436bd48d.png

  1. アプリで [RSVP] ボタンをタップして 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 を使用してフォームの背後にあるフォームの状態にアクセスします。鍵とその使用方法の詳細については、鍵を使用するタイミングをご覧ください。

また、ウィジェットのレイアウト方法にも注目してください。Row には TextFormFieldStyledButton があり、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 の子レコードの末尾にコレクションを追加して、メッセージ リスト内の各メッセージに新しい 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 ファイルで、UI コードがこの状態を操作できるように、ApplicationState の accessors セクションに次の行を追加します。

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 コレクションで、ドキュメント名として使用した Authentication 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 を使用して、インタラクティブなリアルタイム ウェブアプリを作成しました。

詳細