Flutter 채팅앱 만들기 – Firebase 파이어베이스 플러터 채팅 구현 (3)

Firebase와 같은 백엔드 서비스를 활용하여 사용자 간의 소통을 가능하게 하는 Flutter 채팅앱을 만들어보겠습니다. 지난 글에서 이미 로그인 기능을 구현하였기 때문에 이번 글에서는 실시간 채팅 기능을 추가하는 방법에 대해서 작성하겠습니다.

패키지 설치

Firebase Authentication은 사용자의 로그인 및 회원가입 과정을 관리합니다. 이 서비스를 통해 사용자 인증을 간편하게 처리할 수 있으며, 다양한 로그인 옵션(이메일/비밀번호, 소셜 미디어 계정 등)을 제공합니다

Cloud Firestore은 실시간 데이터베이스 서비스로, 사용자 간의 메시지 교환을 실시간으로 처리합니다. Cloud Firestore는 NoSQL 데이터베이스로, 데이터를 문서와 컬렉션으로 구성하여 관리합니다. 채팅 앱에서는 사용자가 보내는 각 메시지를 문서로 저장하고, 데이터베이스에 변화가 생길 때마다 자동으로 앱에 알려주어 UI를 즉시 업데이트할 수 있습니다.

  firebase_auth: ^4.17.8
  firebase_core: ^2.27.0
  cloud_firestore: ^4.15.8

로그인 기능 구현

저번 글에서 로그인 기능을 구현하고 로그인 성공시에 HomePage() 화면으로 Navigator.push()을 이용하여 이동하였습니다. 아직 해당 내용을 모르시는 분들은 아래 글을 참고하시고 진행해주시기 바랍니다.

Flutter-채팅앱-로그인

코드가 길어지기 때문에 새로운 chat_screen.dart파일에 ChatScreen() 클래스를 만들어 줍니다.

import 'package:flutter/material.dart';

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

새롭게 만든 ChatScreen() 클래스를 import 해주고 로그인 성공시 이동되는 페이지인 HomePage(user: userCredential.user!)을 ChatScreen()으로 수정합니다.

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';

import 'chat_screen.dart';

...
Future<void> _signIn() async {
  try {
    final UserCredential userCredential =
        await _auth.signInWithEmailAndPassword(
      email: _emailController.text,
      password: _passwordController.text,
    );

    if (userCredential.user != null) {
      if (!userCredential.user!.emailVerified) {
        setState(() {
          _statusMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.';
        });
      } else {
        // 로그인 성공 처리, 예: 홈 화면으로 이동
        setState(() {
          _statusMessage = '로그인 성공!';
          Navigator.push(
              context, MaterialPageRoute(builder: (context) => ChatScreen()));
        });
        // 여기에 홈 화면으로 이동하는 코드를 추가합니다.
      }
    }
  } on FirebaseAuthException catch (e) {
    setState(() {
      _statusMessage = e.message ?? '로그인 실패';
    });
  }
}
...

채팅 기능 구현

사용자 정보 변수 선언

_controller는 텍스트 필드의 입력값을 관리하는 TextEditingController 인스턴스입니다. _auth는 Firebase 인증을 위한 FirebaseAuth 인스턴스입니다. loggedInUser는 로그인한 사용자의 정보를 저장하는 변수로, null safety를 고려하여 User? 타입으로 선언하였습니다.

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

class ChatScreen extends StatefulWidget {
  @override
  _ChatScreenState createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
  final _controller = TextEditingController();
  final _auth = FirebaseAuth.instance;
  User? loggedInUser;

...

유저 정보 초기화

initState 메서드는 위젯의 생명주기에서 가장 먼저 호출되는 메서드로, 여기서 getCurrentUser 메서드를 호출하여 로그인한 사용자의 정보를 가져옵니다. getCurrentUser 메서드는 현재 로그인한 사용자의 정보를 _auth.currentUser로부터 가져오고, 해당 정보를 loggedInUser 변수에 저장합니다. 에러 발생 시 콘솔에 출력합니다.

  @override
  void initState() {
    super.initState();
    getCurrentUser(); // 앱이 시작될 때 현재 로그인한 사용자의 정보를 가져오는 함수를 호출
  }

  void getCurrentUser() {
    try {
      final user = _auth.currentUser; // FirebaseAuth 인스턴스를 통해 현재 로그인한 사용자의 정보
      if (user != null) { // 만약 사용자 정보가 null이 아니라면 (즉, 사용자가 로그인한 상태라면)
        loggedInUser = user; // loggedInUser 변수에 사용자 정보를 저장
        print(loggedInUser!.email); // 콘솔에 로그인한 사용자의 이메일을 출력 (이메일은 사용자 식별에 사용)
      }
    } catch (e) {
      print(e); // 에러 메시지 출력
    }
  }

채팅 기능 추가

_sendMessage 메서드는 사용자가 메시지를 보낼 때 호출되며, 입력된 텍스트의 앞뒤 공백을 제거하고, 텍스트가 비어 있지 않은 경우 Firestore의 chats 컬렉션에 메시지 정보를 추가합니다. 메시지 정보에는 메시지 내용(text), 발신자 이메일(sender), 메시지 발송 시간(timestamp)이 포함됩니다.

  void _sendMessage() {
    _controller.text = _controller.text.trim(); // 사용자가 입력한 텍스트 공백을 제거

    if (_controller.text.isNotEmpty) { // 사용자가 무언가를 입력했다면
      FirebaseFirestore.instance.collection('chats').add({
        'text': _controller.text, // 'text' 필드에 사용자가 입력한 메시지를 저장
        'sender': loggedInUser!.email, // 'sender' 필드에 로그인한 사용자의 이메일을 식별을 위해 저장
        'timestamp': Timestamp.now(), // 'timestamp' 필드에 메시지가 전송된 현재 시간을 저장(시간 순으로 정렬)
      });
      _controller.clear(); // 메시지 전송 후, 텍스트 필드를 비워 다음 메시지 입력을 준비
    }
  }

UI 만들기

StreamBuilder를 사용하여 Firestore에서 실시간으로 업데이트되는 메시지 목록을 표시합니다. 메시지는 시간 순서대로 내림차순으로 정렬됩니다. 메시지 입력 필드와 전송 버튼을 포함한 행이 페이지 하단에 배치됩니다. 사용자가 메시지를 입력하고 전송 버튼을 누르면 _sendMessage 메서드가 호출됩니다.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Chat'),
        actions: [
          IconButton(
            icon: Icon(Icons.exit_to_app), // 로그아웃 아이콘 버튼
            onPressed: () {
              _auth.signOut(); // 로그아웃 함수 실행: FirebaseAuth 인스턴스를 사용하여 현재 로그인된 사용자를 로그아웃
              Navigator.pop(context); // 로그아웃 후 이전 화면으로 돌아감
            },
          ),
        ],
      ),
      body: Column(
        children: [
          // 메시지 목록을 표시하는 부분
          Expanded(
            child: StreamBuilder<QuerySnapshot>(
              stream: FirebaseFirestore.instance
                  .collection('chats') // 'chats' 컬렉션에 대한 스트림을 생성
                  .orderBy('timestamp', descending: true) // 메시지를 타임스탬프 기준으로 내림차순 정렬
                  .snapshots(), // 실시간 데이터 스냅샷을 가져옴
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return Center(child: CircularProgressIndicator()); // 데이터 로딩 중 표시
                }
                final chatDocs = snapshot.data!.docs; // 스냅샷에서 문서 데이터를 가져옴
                return ListView.builder(
                  reverse: true, // 최신 메시지가 아래가 아닌 위에 표시되도록 리스트를 반전
                  itemCount: chatDocs.length, // 메시지의 개수만큼 아이템을 생성
                  itemBuilder: (ctx, index) {
                    bool isMe =
                        chatDocs[index]['sender'] == loggedInUser!.email; // 메시지가 현재 사용자에 의해 보내진 것인지 확인
                    return Row(
                      mainAxisAlignment: isMe
                          ? MainAxisAlignment.end // 만약 현재 사용자의 메시지라면, 오른쪽 정렬
                          : MainAxisAlignment.start, // 타인의 메시지라면, 왼쪽 정렬
                      children: [
                        Container(
                          // 메시지의 디자인 및 표시를 위한 컨테이너 (아래 코드 참고하여 추가)
                        ),
                      ],
                    );
                  },
                );
              },
            ),
          ),
          Padding(
            // 메시지 입력 필드 및 전송 버튼 (아래 코드 참고하여 추가)
          ),
        ],
      ),
    );
  }

메시지 표시

현재 사용자가 보낸 메시지와 다른 사용자가 보낸 메시지를 구분합니다

Container(
  padding: EdgeInsets.symmetric(
      vertical: 10, horizontal: 16), // 컨테이너 내부의 위젯들에 대해 수직 및 수평 방향으로 패딩을 적용
  margin: EdgeInsets.symmetric(vertical: 4, horizontal: 8), // 컨테이너 외부의 공간에 대해 수직 및 수평 방향으로 마진을 설정
  decoration: BoxDecoration(
    color: isMe ? Colors.grey[300] : Colors.grey[500], // 메시지가 현재 사용자에 의해 보내진 경우 밝은 회색, 아닐 경우 더 어두운 회색
    borderRadius: isMe
        ? BorderRadius.only(
            topLeft: Radius.circular(14), // 현재 사용자의 메시지일 경우, 오른쪽 아래 모서리를 제외한 모든 모서리를 둥글게 처리
            topRight: Radius.circular(14),
            bottomLeft: Radius.circular(14),
          )
        : BorderRadius.only(
            topLeft: Radius.circular(14), // 다른 사용자의 메시지일 경우, 오른쪽 위 모서리를 제외한 모든 모서리를 둥글게 처리
            topRight: Radius.circular(14),
            bottomRight: Radius.circular(14),
          ),
  ),
  child: Text(
    chatDocs[index]['text'], // Firestore에서 가져온 메시지 텍스트를 표시
    style: TextStyle(fontSize: 16), // 메시지 텍스트의 스타일
  ),
),

메시지 입력 버튼

메시지를 입력하고 전송하는 UI 구성 요소를 구현합니다.

Padding(
  padding: const EdgeInsets.all(20.0), // 이 위젯 전체에 대해 모든 방향으로 20.0의 패딩
  child: Row(
    children: [
      Expanded(
        child: TextField(
          controller: _controller, // 텍스트 입력을 위한 TextEditingController
          decoration: InputDecoration(labelText: 'Send a message...'), // 입력 필드에 레이블 텍스트를 표시
        ),
      ),
      IconButton(
        icon: Icon(Icons.send), // 'send' 아이콘을 가진 버튼을 생성
        onPressed: _sendMessage, // 아이콘 버튼을 누르면 _sendMessage 함수가 호출
      ),
    ],
  ),
),

앱 두개 실행 (테스트 방법)

안드로이드 스튜디오 Run버튼에서 Edit Config에 들어갑니다. 새로운 main.dart를 상단에 복사버튼을 눌러 추가합니다.

IOS인 경우는 시뮬레이터에서 open simulator에서 새로운 디바이스를 생성합니다. (안드로이드는 상단에Device Manager을 이용하여 디바이스 생성)

Flutter-채팅앱-앱-두개-실행

새롭게 만든 config로 디바이스를 변경하여 실행합니다.

Flutter-채팅앱-앱-두개-실행

Firebase Authentication으로 가입한 아이디로 로그인하고 앱 두개를 열어 테스트 하였습니다.

Flutter-채팅앱-앱-결과물

전체코드

chat_screen.dart 파일의 전체 코드 입니다. 참고부탁드립니다.

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

class ChatScreen extends StatefulWidget {
  @override
  _ChatScreenState createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final _controller = TextEditingController();
  final _auth = FirebaseAuth.instance;
  User? loggedInUser;

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

  void getCurrentUser() {
    try {
      final user = _auth.currentUser;
      if (user != null) {
        loggedInUser = user;
        print(loggedInUser!.email);
      }
    } catch (e) {
      print(e);
    }
  }

  void _sendMessage() {
    _controller.text = _controller.text.trim();
    if (_controller.text.isNotEmpty) {
      FirebaseFirestore.instance.collection('chats').add({
        'text': _controller.text,
        'sender': loggedInUser!.email,
        'timestamp': Timestamp.now(),
      });
      _controller.clear();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Chat'),
        actions: [
          IconButton(
            icon: Icon(Icons.exit_to_app),
            onPressed: () {
              _auth.signOut();
              Navigator.pop(context);
            },
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder<QuerySnapshot>(
              stream: FirebaseFirestore.instance
                  .collection('chats')
                  .orderBy('timestamp', descending: true)
                  .snapshots(),
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return Center(child: CircularProgressIndicator());
                }
                final chatDocs = snapshot.data!.docs;
                return ListView.builder(
                  reverse: true,
                  itemCount: chatDocs.length,
                  itemBuilder: (ctx, index) {
                    bool isMe =
                        chatDocs[index]['sender'] == loggedInUser!.email;
                    return Row(
                      mainAxisAlignment: isMe
                          ? MainAxisAlignment.end
                          : MainAxisAlignment.start,
                      children: [
                        Container(
                          padding: EdgeInsets.symmetric(
                              vertical: 10, horizontal: 16),
                          margin:
                              EdgeInsets.symmetric(vertical: 4, horizontal: 8),
                          decoration: BoxDecoration(
                            color: isMe ? Colors.grey[300] : Colors.grey[500],
                            borderRadius: isMe
                                ? BorderRadius.only(
                                    topLeft: Radius.circular(14),
                                    topRight: Radius.circular(14),
                                    bottomLeft: Radius.circular(14),
                                  )
                                : BorderRadius.only(
                                    topLeft: Radius.circular(14),
                                    topRight: Radius.circular(14),
                                    bottomRight: Radius.circular(14),
                                  ),
                          ),
                          child: Text(
                            chatDocs[index]['text'],
                            style: TextStyle(fontSize: 16),
                          ),
                        ),
                      ],
                    );
                  },
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(20.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: InputDecoration(labelText: 'Send a message...'),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.send),
                  onPressed: _sendMessage,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Leave a Comment