Skip to content

WangShuan/flutter-07-chat-app

Repository files navigation

使用 Flutter 創建聊天室 app

設置及初始化 Firebase

首先要安裝 Firebase SDK 以簡化使用 Firebase 的大部分流程,可參考官方文檔: https://firebase.google.com/docs/flutter/setup?hl=zh-tw&platform=ios

安裝步驟如下:

  1. 執行指令 npm install -g firebase-tools 安裝 Firebase CLI
  2. 執行指令 firebase login 登入你的 Firebase 帳號(會自動開啟瀏覽器讓你選擇登入的 Google 帳號)
  3. 執行指令 dart pub global activate flutterfire_cli 安裝 Firebase CLI (有可能需要加上 sudo 才可執行)
  4. 執行命令 flutterfire configure 讓 flutter 項目綁定 Firebase 專案(步驟依序為選擇 Firebase 專案、選擇要使用的平台(選 IOS 與 Android) 、直接 Enter Yes 同意自動變更檔案)

執行第四步時如果出現錯誤 zsh: command not found: flutterfire 請開啟 .zshrc 檔案,添加 export PATH="$PATH":"$HOME/.pub-cache/bin" 保存並重開終端機。 執行第四步時如果出現錯誤 no active package flutterfire_cli. 請執行命令 sudo flutterfire configure 即可正確運行。

最後回到 flutter 專案中開啟 main.dart 檔案,修改以下內容以進行初始化:

// 引入
import 'package:firebase_core/firebase_core.dart';
import './firebase_options.dart';

//改寫 main 函數
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const App());
}

完成後請將 flutter 執行中的應用程式完全關閉並重新啟動即可。

如出現錯誤找不到 project.pbxproj 檔案,請用 Finder 進入檔案所在位置,並點擊右鍵,選擇取得資訊,更改讀取與寫入權限,再將 flutter 項目重啟即可。

使用 Authentication 服務(package: firebase_auth)

這邊要通過 firebase_auth 使用 Firebase 的 Authentication 服務讓用戶可通過電子郵件及密碼進行登入、註冊等功能。

登入與註冊

先到 Firebase 控制台中,點擊進入 “建構 > Authentication” 啟用 “電子郵件/密碼” 登入供應商,接著回到 flutter 項目中安裝 firebase_auth 並將 flutter 執行中的應用程式完全關閉,重新啟動。

接下來即可到 auth_screen.dart 中,在 _submit 函數裡面藉由判斷 isLogin 分別處理登入與註冊事件:

// 引入
import 'package:firebase_auth/firebase_auth.dart';
// 宣告
final _firebase = FirebaseAuth.instance;

// 使用
Future<void> _submit() async {
  if (!formKey.currentState!.validate()) return; // 驗證表單
  formKey.currentState!.save(); // 儲存表單內容
  if (_isLogin) { // 判斷是否為登入
    try {
      // 通過 _firebase.signInWithEmailAndPassword 傳入信箱與密碼進行登入
      await _firebase.signInWithEmailAndPassword(email: _mail!, password: _pwd!);
    } on FirebaseAuthException catch (e) { // 處理 firebase 提供的錯誤內容
      switch (e.code) {
        case 'user-not-found':
          _showSnackBar('找不到對應於該電子郵件的使用者。');
          break;
        case 'wrong-password':
          _showSnackBar('您輸入的帳號或密碼錯誤。');
          break;
        case 'invalid-email':
          _showSnackBar('您輸入的電子郵件地址無效。');
          break;
        case 'user-disabled':
          _showSnackBar('該使用者的帳號已被停用。');
          break;
        default:
          _showSnackBar(e.code);
          break;
      }
    } catch (e) { // 處理其他錯誤
      _showSnackBar(e.toString());
    }
  } else {
    try {
      // 通過 _firebase.createUserWithEmailAndPassword 傳入信箱與密碼進行註冊
      await _firebase.createUserWithEmailAndPassword(email: _mail!, password: _pwd!);
    } on FirebaseAuthException catch (e) { // 處理 firebase 提供的錯誤內容
      switch (e.code) {
        case 'email-already-in-use':
          _showSnackBar('此電子郵件地址已被使用。');
          break;
        case 'invalid-email':
          _showSnackBar('您輸入的電子郵件地址無效。');
          break;
        case 'operation-not-allowed':
          _showSnackBar('電子郵件/密碼註冊功能尚未啟用。');
          break;
        case 'weak-password':
          _showSnackBar('密碼強度不足。');
          break;
        default:
          _showSnackBar(e.code);
          break;
      }
    } catch (e) { // 處理其他錯誤
      _showSnackBar(e.toString());
    }
  }
}

登出

建立 chat_screen.dart 檔案,簡單設置好 appBar 小部件,在 actions 中新增 IconButton 綁定點擊事件,使用 FirebaseAuth.instance.signOut() 進行登出:

appBar: AppBar(
  title: const Text('CHAT APP'),
  actions: [
    IconButton(
      onPressed: () => FirebaseAuth.instance.signOut(),
      icon: const Icon(Icons.logout_rounded),
    ),
  ],
)

判斷當前用戶資訊

main.dart 檔案中的 home 區塊內容改寫如下:

StreamBuilder(
  stream: FirebaseAuth.instance.authStateChanges(), // 獲取當前用戶資訊
  builder: (context, snapshot) => snapshot.connectionState == ConnectionState.waiting
      ? Scaffold( // 載入中顯示 loading 畫面
          appBar: AppBar(title: const Text('CHAT APP')),
          body: const Center(child: CircularProgressIndicator()),
        )
      : snapshot.hasData // 判斷有無資訊
          ? const ChatScreen() // 如果已登入則顯示 ChatScreen 畫面
          : const AuthScreen(), // 如果未登入則顯示 AuthScreen 畫面
)

使用 Storage 服務(package: firebase_storage)

這邊要透過 firebase_storageimage_picker 讓用戶於註冊時可拍攝上傳用戶頭像並將圖片保存到 Firebase 的 Storage 服務中。

先到 Firebase 控制台中,點擊進入 “建構 > Storage” 啟用服務(選擇正式版本、選個亞洲地區即可),完成後點擊 Rules 修改規則,將 if false 更改為 if request.auth != null 限制已登入的用戶才可以進行讀取及寫入,並點擊發布規則,接下來回到 flutter 專案中,安裝 firebase_storageimage_picker 用來保存用戶頭像,安裝好 pub 記得要將 flutter 執行中的應用程式完全關閉並重新啟動。

接著回到 auth_screen.dart 中通過 _submit 方法,於註冊時,藉由 createUserWithEmailAndPassword 方法獲取回傳的結果為 res ,通過 res.user.uid 獲取用戶 uid ,即可將圖片檔案設置名稱為 '$uid.jpg' 後上傳到 firebase_storage 中,並通過 firebase_storage 提供的 getDownloadURL 方法得到圖片網址。

整體主要程式碼如下:

try {
  final userCredential = await _firebase.createUserWithEmailAndPassword(email: _mail!, password: _pwd!); // 獲取用戶資料

  final storageRef = FirebaseStorage.instance.ref().child("user_images").child("${userCredential.user!.uid}.jpg"); // 設置欲上傳的檔案路徑
  
  try {
    await storageRef.putFile(_selectedImg!); // 上傳文件
    final imgUrl = await storageRef.getDownloadURL(); // 獲取文件 URL
    
    await userCredential.user?.updatePhotoURL(imgUrl); // 設置用戶頭像
  } on FirebaseException catch (e) { // 處理 FirebaseStorage 的錯誤
    _handleError(e);
  }
} on FirebaseAuthException catch (e) { // 處理 FirebaseAuth 的錯誤
  _handleError(e);
} catch (e) { // 處理其他錯誤
  _handleError(e.toString());
}

使用 Firestore Database 服務(package: cloud_firestore)

首先要到 Firebase 控制台中,點擊進入 “建構 > Firestore Database” ,這邊會顯示要你到 Google Cloud 控制台,請先點擊進入 Google Cloud 控制台,並點擊切換到本地模式,,完成後回到 Firebase 控制台,點擊 Rules 修改規則,將 if false 更改為 if request.auth != null 限制已登入的用戶才可以進行讀取及寫入,並點擊發布規則,接下來回到 flutter 專案中,安裝 cloud_firestore 即可。

上述步驟皆完成後,請記得要將 flutter 執行中的應用程式完全關閉並重新啟動。

如果重啟服務時一直停在 pod install 可透過以下步驟改善:

  1. 開啟 ios/Podfile 檔案
  2. 找到 target 'Runner' do 的程式碼
  3. 於下一行貼上 pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '7.11.0'
  4. '7.11.0' 替換為 https://github.com/firebase/flutterfire/blob/master/packages/firebase_core/firebase_core/ios/firebase_sdk_version.rb 網址中顯示的版本
  5. 再次重新啟動服務,即可縮短編譯時間

保存用戶資料

auth_screen.dart 的註冊函數中,需添加一段程式碼,將用戶註冊的資料保存到 Firestore Database :

try {
  final userCredential = await _firebase.createUserWithEmailAndPassword(email: _mail!, password: _pwd!); // 獲取用戶資料

  final storageRef = FirebaseStorage.instance.ref().child("user_images").child("${userCredential.user!.uid}.jpg"); // 設置欲上傳的檔案路徑
  
  try {
    await storageRef.putFile(_selectedImg!); // 上傳文件
    final imgUrl = await storageRef.getDownloadURL(); // 獲取文件 URL
    
    await userCredential.user?.updatePhotoURL(imgUrl); // 設置用戶頭像

    final db = FirebaseFirestore.instance;

    // 添加的內容如下,透過 collection 建立集合 users => 透過 doc 建立文檔 uid => 透過 set 設置文檔內容
    await db.collection('users').doc(userCredential.user!.uid).set({
      'username': _name, // 添加一個 TextFormField 小部件用來設置姓名並傳到此處
      'email': _mail,
      'image_url': imgUrl, // 傳入上方通過 FirebaseStorage 獲取到的文件 URL
    });
  } on FirebaseException catch (e) { // 處理 FirebaseStorage 的錯誤
    _handleError(e);
  }
} on FirebaseAuthException catch (e) { // 處理 FirebaseAuth 的錯誤
  _handleError(e);
} catch (e) { // 處理其他錯誤
  _handleError(e.toString());
}

發送並保存訊息

回到 chat_screen.dart 中,在聊天屏幕上方應該要有一個可滾動的區塊,用以顯示所有訊息,而最下方則要有一個文字輸入框及傳送按鈕用來新增訊息,所以這邊總共要再建立兩個子部件,分別是 chat_messages.dart 以及 new_message.dart

chat_messages.dart 中可通過 FirebaseFirestore.instance.collection('messages').snapshots() 獲取 stream 即時顯示所有訊息:

final user = FirebaseAuth.instance.currentUser; // 獲取當前登入者資訊
final Stream<QuerySnapshot<Map<String, dynamic>>> messagesStream = FirebaseFirestore.instance.collection('messages').orderBy("create_at", descending: true).snapshots(); // 設置 stream,可通過 .orderBy("create_at", descending: true) 設置 data 排序方式

Padding(
  padding: const EdgeInsets.symmetric(horizontal: 16),
  child: StreamBuilder( // 通過 StreamBuilder 獲取結果
    stream: messagesStream, // 傳入上方宣告好的 stream 
    builder: (context, snapshot) {
      if (snapshot.connectionState == ConnectionState.waiting) {
        return const Center(
          child: CircularProgressIndicator(),
        );
      } else if (snapshot.hasError) {
        return const Center(
          child: Text('Something wrong here.'),
        );
      } else if (!snapshot.hasData || snapshot.data!.docs.isEmpty) { // 判斷是否有值&該值底下的文檔是否為空
        return const Center(
          child: Text('No messages here.'),
        );
      } else {
        return ListView.builder( // 使用 ListView.builder 建立滾動區域
          padding: const EdgeInsets.all(0),
          reverse: true, // 設置排序方式為倒敘,可確保最新的項目永遠為在畫面最下方
          itemCount: snapshot.data!.docs.length,
          itemBuilder: (context, index) => MessageItem(user, snapshot.data!.docs[index].data()),
        );
      }
    },
  ),
);

new_message.dart 中則可通過 FirebaseFirestore.instance.collection('messages').add() 添加訊息到 Firestore Database 中:

Future<void> _sendMessage() async {
  final msg = _messageConntroller.text; // 獲取輸入的內容

  if (msg.isEmpty || msg.trim().isEmpty) return; // 判斷是否有值

  FocusScope.of(context).unfocus(); // 關閉鍵盤
  _messageConntroller.clear(); // 清空輸入框

  final User? user = FirebaseAuth.instance.currentUser; // 獲取當前登入用戶資訊

  final db = FirebaseFirestore.instance;

  await db.collection('messages').add({ // 添加訊息資料到 Firestore Database 中
    'user_id': user!.uid, // 傳入 uid
    'user_image': user.photoURL, // 傳入頭像
    'username': user.displayName, // 傳入姓名
    'text': msg, // 傳入輸入的內容
    'create_at': Timestamp.now(), // 傳入當前時間
  });
}

使用 Firebase Messaging 服務(package: firebase_messaging)

這邊用來再有人傳送新訊息時,向其他人的設備發送推播通知。

初始化設定,針對 ios 請先用 Xcode 開啟專案項目的 ios/Runner.xcworkspace 檔案,接著在左側檔案列表中點擊 Runner ,右側主畫面中點擊 Signing & Capabilities

首先要啟用推送通知,請點擊 + Capability 於搜尋欄位輸入 push ,雙擊結果中的 Push Notifications 啟用它,然後會看到主畫面中顯示一些錯誤警告,請將 Bundle Identifier 設置成唯一值,接著點擊鍵盤的 Enter 鍵保存。

接著要啟用後台獲取和遠程通知後台執行模式,請點擊 + Capability 於搜尋欄位輸入 back ,雙擊結果中的 Background Modes 啟用它,然後會看到出現一些可勾選的項目,請將 Background fetchRemote notifications 打勾即可。

然後進入 Apple 開發者頁面,登入付費的開發者帳號,點擊進入 Certificates, IDs & Profiles 底下的 Keys 中,點擊左上角+號新增 key ,設置可識別的名稱,並將 Apple Push Notifications service (APNs) 勾選,點擊右上角繼續,再點擊註冊,然後點擊下載檔案將金鑰的 .p8 檔案保存好,然後切記先不要關閉當前頁面。

接下來先進入 Firebase 控制台中,點擊專案設定,點擊雲端通訊,在 Apple 應用程式設定中選擇你的 ios 應用程式,於 APN 驗證金鑰處點擊上傳,選擇剛才的 .p8 檔案,輸入金鑰 ID (在 Apple 開發者頁面下載金鑰的 Key ID)與團隊 ID(在 Apple 開發者頁面的右上角你名字旁邊),點擊上傳。

然後一樣在 Firebase 控制台中的專案設定,點進一般設定裡面,將目前已經存在的應用程式刪除,重新回到 flutter 項目中執行指令 flutterfire configure ,以更新稍早變更過的 Bundle Identifier

接著再執行指令 flutter pub add firebase_messaging 安裝 Messaging 用的 package ,安裝好後記得關閉應用程式並重新啟動,然後在 chat_screen.dart 中獲取設備 token 稍後用來測試發送通知:

void setupMsg() async {
  await FirebaseMessaging.instance.requestPermission();
  final t = await FirebaseMessaging.instance.getToken();
  print('token:$t');
}

@override
void initState() {
  setupMsg();
  super.initState();
}

接著於 flutter 應用程式中登入或註冊帳號,並將 chat app 滑到背景執行。

最後進入 Firebase 控制台中,點擊左側 “互動交流 > Messaging” ,點擊建立第一個廣告活動,選擇 Firebase 通知訊息,輸入通知標題及通知文字,點擊傳送測試訊息,將剛才的 token 貼到 “新增 FCM 註冊憑證” 上並點擊 “測試” 即可收到推播通知,點擊通知即可開啟應用程式。

使用 Firebase Functions 服務

這邊主要是用來自動化的發送推播通知,上一段我們都是通過 Firebase 控制台手動測試發送通知,實際上則需要藉由後端處理自動化的程式碼,所以要到 Firebase 中的 “建構 > Functions“ 點擊使用(需要升級付費版本,但可放心,有免費用量的扣打)。

首先會要求你安裝 firebase-tool 請執行指令 sudo npm install -g firebase-tools 進行安裝(必須先安裝 node)

接著按照步驟執行命令 firebase init 啟動專案,第一步是選擇要啟用的功能,這邊只需用空白鍵選取 Functions 即可,接著會問你專案,選擇現有專案即可,然後要選擇使用的編程語言,這邊選 JS ,接著會問要不要啟用 ESLint 選 No ,最後會問要不要安裝依賴項目,請選 Yes ,完成後 flutter 項目中會自動產生新的資料夾 functions 我們主要編寫的後端程式碼在 functions/index.js 檔案中,編寫完畢執行命令 firebase deploy 即可成功部署 Functions 。

functions/index.js 檔案內容如下:

const functions = require('firebase-functions'); // 引入 functions
const admin = require('firebase-admin'); // 引入 admin
admin.initializeApp(); // 初始化 admin 對象

// 定義 sendNotificationOnNewMessage 事件
exports.sendNotificationOnNewMessage = functions.firestore
  .document('messages/{messageId}')
  .onCreate(async (snapshot, context) => { // 在文檔被創建時觸發
    const messageData = snapshot.data();

    const payload = {
      notification: {
        title: messageData.username + '發送了一條訊息',
        body: messageData.text,
        clickAction: 'FLUTTER_NOTIFICATION_CLICK',
      },
    };

    return admin.messaging().sendToTopic("chatapp", payload); // 向主題 chatapp 發送 FCM 消息
  });

要使用主題發送消息,需要在 chat_screen.dart 中讓用戶訂閱主題,該行為應該是被動執行,且要確保用戶曾經登入或註冊過帳號,由於在 chat_screen.dart 的父層會確保用戶進行過身份驗證才能進入該畫面,所以將訂閱事件設置於此:

class _ChatScreenState extends State<ChatScreen> {
  void setupMsg() async { // 添加函數用來訂閱主題
    await FirebaseMessaging.instance.requestPermission();
    FirebaseMessaging.instance.subscribeToTopic("chatapp");
  }

  @override
  void initState() {
    setupMsg(); // 於 initState 中調用訂閱主題的事件
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
    );
  }
}

使用主題發送消息類似於群發的概念,會將消息發送給所有訂閱過該主題的設備。

另外也可以針對單個用戶進行發送,做法即為獲取該用戶的 token 後通過 sendToDevice() 方法將 FCM 消息發送到與提供的 token 相對應的單個設備。

其他發送的方法可參考官方文件說明