認識 Firebase for Flutter

1. 事前準備

在本程式碼研究室中,您將瞭解 Firebase 的一些基本概念,以建立適用於 Android 和 iOS 的 Flutter 行動應用程式。

事前準備

課程內容

  • 如何使用 Flutter 在 Android、iOS、網頁和 macOS 上建構活動回覆和留言板聊天室應用程式。
  • 如何使用 Firebase 驗證功能驗證使用者,並透過 Firestore 同步處理資料。

Android 版應用程式主畫面

iOS 應用程式主畫面

事前準備

下列任一裝置:

  • 將實體 Android 或 iOS 裝置接上電腦,並設為開發人員模式。
  • iOS 模擬器 (需要 Xcode 工具)。
  • Android 模擬器 (需要在 Android Studio 中設定)。

你還需要下列項目:

  • 使用您選擇的瀏覽器,例如 Google Chrome。
  • 您選擇的 IDE 或文字編輯器,並已設定 Dart 和 Flutter 外掛程式,例如 Android StudioVisual Studio Code
  • 最新版本的 stable Flutterbeta,如果您喜歡在邊緣生活。
  • 用於建立及管理 Firebase 專案的 Google 帳戶。
  • 已登入 Google 帳戶的 Firebase CLI

2. 取得程式碼範例

請從 GitHub 下載專案的初始版本:

  1. 在指令列中,複製 flutter-codelabs 目錄中的 GitHub 存放區
git clone https://github.com/flutter/codelabs.git flutter-codelabs

flutter-codelabs 目錄含有一系列程式碼研究室的程式碼。本程式碼研究室的程式碼位於 flutter-codelabs/firebase-get-to-know-flutter 目錄中。目錄包含一系列快照,顯示專案在每個步驟結束時的樣貌。例如,你目前在第二個步驟

  1. 找出第二個步驟的相符檔案:
cd flutter-codelabs/firebase-get-to-know-flutter/step_02

如果您想略過某些步驟,或查看某個步驟後的內容,請前往該步驟名稱對應的目錄。

匯入範例應用程式

  • 在想用的 IDE 中開啟或匯入 flutter-codelabs/firebase-get-to-know-flutter/step_02 目錄。這個目錄包含程式碼研究室的範例程式碼,其中包含尚未啟用的 Flutter 聚會應用程式。

找出需要處理的檔案

這個應用程式中的程式碼分散在多個目錄中。這種功能劃分功能可讓程式碼依功能分組,因此可讓工作更輕鬆。

  • 找出下列檔案:
    • lib/main.dart:這個檔案包含主要進入點和應用程式小工具。
    • lib/home_page.dart:這個檔案包含主畫面小工具。
    • lib/src/widgets.dart:這個檔案包含一些小工具,可協助將應用程式樣式標準化,並組成啟動器應用程式的畫面。
    • lib/src/authentication.dart:這個檔案包含部分實作的驗證,其中包含一組小工具,可為 Firebase 電子郵件驗證建立登入使用者體驗。這些驗證流程的小工具尚未在範例應用程式中使用,但你很快就會新增。

您可以視需要新增其他檔案,以建構應用程式的其餘部分。

查看 lib/main.dart 檔案

這個應用程式會利用 google_fonts 套件,將 Roboto 設為應用程式中的預設字型。您可以前往 fonts.google.com 瀏覽字型,並在應用程式的不同部分使用這些字型。

您可以使用 lib/src/widgets.dart 檔案中的輔助小工具,格式為 HeaderParagraphIconAndDetail。這些小工具可消除重複的程式碼,減少 HomePage 中所述的頁面版面配置雜亂情形。還能維持一致的外觀和風格。

以下是應用程式在 Android、iOS、網頁和 macOS 上的外觀:

Android 版應用程式主畫面

iOS 應用程式主畫面

網頁應用程式的主畫面

macOS 上的應用程式主畫面

3. 建立及設定 Firebase 專案

顯示事件資訊對住客來說很實用,但對其他人來說並沒有太大幫助。你必須在應用程式中新增一些動態功能,或是將 Firebase 連結至應用程式。如要開始使用 Firebase,你必須建立並設定 Firebase 專案。

建立 Firebase 專案

  1. 登入 Firebase
  2. 在控制台中,按一下「新增專案」或「建立專案」
  3. 在「專案名稱」欄位中輸入「Firebase-Flutter-Codelab」,然後按一下「繼續」

4395e4e67c08043a.png

  1. 點選專案建立選項。如果系統提示,請接受 Firebase 條款,但略過 Google Analytics 設定,因為您不會在這個應用程式中使用這項服務。

b7138cde5f2c7b61.png

如要進一步瞭解 Firebase 專案,請參閱「瞭解 Firebase 專案」一文。

應用程式會使用下列 Firebase 產品,這些產品可用於網頁應用程式:

  • 驗證:讓使用者登入您的應用程式。
  • Firestore:在雲端儲存結構化資料,並在資料變更時收到即時通知。
  • Firebase 安全性規則:保護資料庫。

部分產品需要特殊設定,或您必須在 Firebase 控制台中啟用。

啟用電子郵件登入驗證

  1. 在 Firebase 控制台的「專案總覽」窗格中,展開「建構」選單。
  2. 依序點選「驗證」>「開始使用」>「登入方式」>「電子郵件/密碼」>「啟用」>「儲存」

58e3e3e23c2f16a4.png

設定 Firestore

網頁應用程式會使用 Firestore 儲存即時通訊訊息,以及接收新的即時通訊訊息。

以下說明如何在 Firebase 專案中設定 Firestore:

  1. 在 Firebase 控制台的左側面板中,展開「Build」,然後選取 「Firestore database」
  2. 按一下 [Create database] (建立資料庫)。
  3. 保留「Database ID」(default)
  4. 選取資料庫位置,然後點選「下一步」
    如果是實際的應用程式,建議您選擇靠近使用者的位置。
  5. 按一下「以測試模式啟動」。請詳閱安全性規則免責事項。
    在本程式碼研究室的後續部分,您將新增安全性規則來保護資料。請勿發布或公開應用程式,除非您已為資料庫新增安全性規則。
  6. 按一下「建立」

4. 設定 Firebase

如要將 Firebase 與 Flutter 搭配使用,必須先完成下列工作來設定 Flutter 專案,才能正確使用 FlutterFire 程式庫:

  1. FlutterFire 依附元件新增至專案。
  2. 在 Firebase 專案中註冊所需的平台。
  3. 下載平台專用的設定檔,然後新增至程式碼。

在 Flutter 應用程式的頂層目錄中,有 androidiosmacosweb 子目錄,分別保存了 iOS 和 Android 平台專用的設定檔。

設定依附元件

您需要為此應用程式中使用的兩項 Firebase 產品 (驗證和 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

您已新增必要套件,但還需要設定 iOS、Android、macOS 和 Web Runner 專案,才能妥善使用 Firebase。您也可以使用 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 新增至應用程式,可以建立「回覆」按鈕,透過「驗證」來註冊使用者。針對 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 應用程式狀態物件是這個步驟的主要職責,是要通知小工具樹狀結構有更新到已驗證的狀態。

您只需使用提供者,即可將使用者的登入狀態傳達給應用程式。如要讓使用者登入,您可以使用 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 小工具中。在應用程式狀態變更時,使用者可透過 Consumer 小工具,以 provider 套件重建樹狀結構的部分內容。AuthFunc 小工具是您測試的補充小工具。

測試驗證流程

cdf2d25e436bd48d.png

  1. 在應用程式中輕觸「回覆」按鈕,即可啟動 SignInScreen

2a2cd6d69d172369.png

  1. 輸入電子郵件地址。如果您已註冊,系統會提示您輸入密碼。否則,系統將提示您填寫註冊表單。

e5e65065dba36b54.png

  1. 請輸入長度不超過六個字元的密碼,檢查錯誤處理流程。如果已註冊,畫面上會顯示密碼。
  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,後者包含 Row。請注意,TextFormField 會包裝在 Expanded 小工具中,這會強制 TextFormField 填入列中的任何額外空間。如要進一步瞭解這項限制的原因,請參閱瞭解限制一文。

您現在已經有了可讓使用者輸入文字並新增至留言板的小工具,接下來請將小工具顯示在畫面上。

  1. 編輯 HomePage 的內容,在 ListView 的子項結尾處加入下列兩行:
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 是指向驗證系統為所有登入使用者提供的自動產生專屬 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 和資料庫

您有一個使用者介面,使用者可以在其中輸入要新增到留言板的文字,而且您也有將項目新增至 Firestore 的程式碼。只要將兩者連結起來即可。

  • 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.
        ],
      ),
    );
  }
}

您已將在本步驟開頭新增的兩行程式碼,替換為完整實作內容。您再次使用 Consumer<ApplicationState>,讓應用程式狀態可供您轉譯的樹狀結構的一部分使用。如此一來,您就可以在使用者介面中輸入訊息,並將訊息發布到資料庫中。在下一節中,您將測試新增的訊息是否已發布至資料庫。

測試傳送訊息

  1. 如有需要,請登入應用程式。
  2. 輸入訊息 (例如 Hey there!),然後按一下「傳送」

這項操作會將訊息寫入 Firestore 資料庫。不過,實際的 Flutter 應用程式不會顯示訊息,因為您仍然需要在下一個步驟中實作擷取資料。不過,您可以在 Firebase 主控台的「資料庫」資訊主頁中,查看 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. 在定義狀態和 getter 的 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.
    );
  }
}

您要使用 Column 小工具納入 build() 方法的先前內容,然後新增適用於 Column 子項尾端的集合,為訊息清單中的每則訊息產生新的 Paragraph

  1. 更新 HomePage 的內容,以便使用新的 messages 參數正確建構 GuestBook

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 控制台的「資料庫」選單中,手動刪除、修改或新增訊息。所有變更都會顯示在使用者介面中。

恭喜!您在應用程式中讀取 Firestore 文件!

應用程式預覽

Android 上整合了即時通訊功能的應用程式主畫面

iOS 版應用程式主畫面 (已整合聊天功能)

整合了即時通訊功能的應用程式網頁版主畫面

macOS 中具有即時通訊整合功能的應用程式主畫面

8. 設定基本安全性規則

您一開始設定 Firestore 時,會使用測試模式,也就是說資料庫會開放讀取和寫入。不過,您應該只在開發初期使用測試模式。最佳做法是在開發應用程式時為資料庫設定安全性規則。安全性是應用程式結構和行為不可或缺的一環。

有了 Firebase 安全性規則,就能控管資料庫中文件和集合的存取權。您可以利用靈活的規則語法,建立可比對所有寫入作業 (從整個資料庫到特定文件的作業) 的規則。

設定基本安全性規則:

  1. 在 Firebase 主控台的「Develop」選單中,依序點選「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. 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();
    });
  }

這段程式碼會新增一個持續訂閱的查詢,以判定參與者人數,以及第二個只在使用者登入期間才會有效的查詢,以判斷使用者是否參加。

  1. lib/app_state.dart 檔案頂端新增下列列舉。

lib/app_state.dart

enum Attending { yes, no, unknown }
  1. 建立新的檔案 yes_no_selection.dart,定義類似圓形按鈕的新小工具:

lib/yes_no_selection.dart

import 'package:flutter/material.dart';

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

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

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

起初處於未定狀態,既未選取「是」也未選取「否」。使用者選取是否出席後,您可以使用填滿的按鈕醒目顯示該選項,並以平面顯示方式顯示其他選項。

  1. 更新 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 建構互動式即時網頁應用程式!

瞭解詳情