Firebase for Flutter を理解する

1. 始める前に

この Codelab では、Android および iOS 向けの Flutter モバイルアプリを作成するための Firebase の基礎を学習します。

前提条件

学習内容

  • 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 アカウント。
  • Firebase CLI が Google アカウントにログインしている。

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

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

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

  • 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 アナリティクスを使用する予定がないため、Google アナリティクスの設定はスキップします。

b7138cde5f2c7b61.png

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

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

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

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

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

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

58e3e3e23c2f16a4.png

Firestore を有効にする

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

Firestore を有効にします。

  • [Build] メニューで、[Firestore Database] > [Create 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 パッケージを使用すると、認証との統合が可能になります。

$ 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、Web Runner プロジェクトを構成する必要もあります。また、ビジネス ロジックを表示ロジックから分離できる 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 用に構築する必要があります。

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

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

また、ウィジェットのレイアウト方法にも注目してください。TextFormField を含む Row と、Row を含む StyledButton があります。また、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.
  }
}

各ゲストブック ドキュメントのフィールドとして認証 UID を使用したため、認証 UID を取得して、ドキュメントに書き込もうとするユーザーが一致する認証 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'),
              ),
            ],
          ),
        );
    }
  }
}

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

詳細