Firebase for Flutter を理解する

1. 始める前に

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

前提条件

学習内容

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

Android でのアプリのホーム画面

iOS 版のアプリのホーム画面

必要なもの

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

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

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

  • 任意のブラウザ(Google Chrome など)。
  • Dart プラグインと Flutter プラグインで構成された任意の IDE またはテキスト エディタ(Android StudioVisual Studio Code など)。
  • 最新の stable バージョンの Flutter または 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. 2 番目のステップで一致するファイルを探します。
cd flutter-codelabs/firebase-get-to-know-flutter/step_02

ステップをスキップする場合や、あるステップの後に何かがどう表示されるかを確認するには、対象のステップにちなんだ名前のディレクトリを確認します。

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

  • お好みの IDE で、flutter-codelabs/firebase-get-to-know-flutter/step_02 ディレクトリを開くかインポートします。このディレクトリには、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 アナリティクスを使用する予定がないため、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 を使用してチャット メッセージの保存と新しいチャット メッセージの受信を行います。

Firestore を有効にします。

  • [構築] メニューで、[Firestore Database >データベースを作成する

99e8429832d23fa3.png

  1. [テストモードで開始] を選択し、セキュリティ ルールに関する免責条項を確認します。テストモードでは、開発中にデータベースに自由に書き込めます。

6be00e26c72ea032.png

  1. [次へ] をクリックし、データベースのロケーションを選択します。デフォルトを使用できます。ロケーションは後で変更できません。

278656eefcfb0216.png

  1. [有効にする] をクリックします。

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 をアプリに追加したので、認証でユーザーを登録する [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 クラスのビルドメソッドで、アプリの状態を 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 コンソールの [データベース] メニューで、メッセージを手動で削除、変更、追加します。すべての変更が 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'),
              ),
            ],
          ),
        );
    }
  }
}

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

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

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 を使用して、インタラクティブなリアルタイム ウェブアプリを作成しました。

詳細