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

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

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

  • お好みの 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 のメールベースの認証でログイン ユーザー エクスペリエンスを作成するためのウィジェットのセットを備えた Authentication の部分実装が含まれています。認証フロー用のこれらのウィジェットはまだスターター アプリでは使用されていませんが、後ほど追加します。

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

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. [Authentication] > [Get Started] > [Sign-in method] > [Email/Password] > [Enable] > [Save] をクリックします。

58e3e3e23c2f16a4.png

Firestore を有効にする

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

Firestore を有効にします。

  • [構築] メニューで、[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 パッケージを使用すると、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、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 用に構築する必要があります。

先ほど取得したプロジェクトには、ほとんどの認証フローのユーザー インターフェースを実装する一連のウィジェットが含まれています。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. アプリで [出欠確認] ボタンをタップして 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 '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 の子の最後にコレクション for を追加して、メッセージのリスト内のメッセージごとに新しい Paragraph を生成します。

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

lib/home_page.dart

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

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

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

メッセージの同期をテストします。

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

お疲れさまでした。アプリで Firestore ドキュメントを読み取ります。

アプリのプレビュー

チャットが統合された Android アプリのホーム画面

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

チャットを統合したウェブ版アプリのホーム画面

チャットを統合した macOS のアプリのホーム画面

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

最初に Firestore をテストモードを使用するようにセットアップしました。つまり、データベースは読み取りと書き込みに対して開かれています。ただし、テストモードは開発の初期段階でのみ使用してください。ベスト プラクティスとして、アプリの開発時にデータベースのセキュリティ ルールをセットアップすることをおすすめします。セキュリティは、アプリの構造と動作に不可欠です。

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

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

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

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 ファイルで、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 つ目のクエリを常時登録するクエリと、ユーザーが参加しているかどうかを判定するためにユーザーがログインしているときにのみアクティブになる 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 コレクションで、ドキュメント名として使用した認証 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 を使用して、インタラクティブなリアルタイムのウェブアプリを作成しました。

詳細