🙄

flutter × firebaseを使用したチャットアプリの開発

2024/03/25に公開

ここではflutterとfirebaseを使用したチャットアプリの開発をしていきます。

前提

チャット機能を開発する上で以下のことを考えて作業を行います。

  • 使用するアーキテクチャ - MVC
  1. M - Model
  2. V - View
  3. C - Controller
  • 作業フロー
  1. Modelの作成
  2. Messageを表示するコンポーネントの作成
  3. メッセージを送信する際のfirebaseとのやりとり(Controller)実装
  4. Viewの作成

Modelの作成

message.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class Message {
  final String senderId;
  final String senderEmail;
  final String receiverId;
  final String message;
  final Timestamp timestamp;
  Message({
    required this.senderId,
    required this.senderEmail,
    required this.receiverId,
    required this.message,
    required this.timestamp,
  });
  Map<String, dynamic> toMap() {
    return {
      'senderId': senderId,
      'senderEmail': senderEmail,
      'receiverId': receiverId,
      'message': message,
      'timestamp': timestamp,
    };
  }
}

上記のクラスのフィールドを一つずつ説明します。

  • senderId - メッセージを送信した人のuid
  • senderEmail - メッセージを送信した人のメールアドレス
  • receiverId - 返信する人(相手)のuid
  • message - メッセージ内容
  • timestamp - メッセージの送信時間

上記のモデル(設計)を使用してメッセージの送信を行います。
本来はsenderEmailの箇所をその人のユーザネームに置き換えます。

Messageを表示するコンポーネントの作成

message_tile.dart
import 'package:flutter/material.dart';

class MessageTile extends StatefulWidget {
  final String message;
  final bool sentByMe;
  final String time;

  const MessageTile(
      {Key? key,
      required this.message,
      required this.sentByMe,
      required this.time})
      : super(key: key);

  
  State<MessageTile> createState() => _MessageTileState();
}

class _MessageTileState extends State<MessageTile> {
  
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.only(
          top: 4,
          bottom: 4,
          left: widget.sentByMe ? 0 : 24,
          right: widget.sentByMe ? 24 : 0),
      alignment: widget.sentByMe ? Alignment.centerRight : Alignment.centerLeft,
      child: Column(
        crossAxisAlignment:
            widget.sentByMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
        children: [
          Container(
            margin: widget.sentByMe
                ? const EdgeInsets.only(left: 30)
                : const EdgeInsets.only(right: 30),
            padding:
                const EdgeInsets.only(top: 17, bottom: 17, left: 20, right: 20),
            decoration: BoxDecoration(
                borderRadius: widget.sentByMe
                    ? const BorderRadius.only(
                        topLeft: Radius.circular(20),
                        topRight: Radius.circular(20),
                        bottomLeft: Radius.circular(20),
                      )
                    : const BorderRadius.only(
                        topLeft: Radius.circular(20),
                        topRight: Radius.circular(20),
                        bottomRight: Radius.circular(20),
                      ),
                color: widget.sentByMe
                    ? Theme.of(context).primaryColor
                    : Colors.grey[700]),
            child: Text(
              widget.message,
              textAlign: TextAlign.start,
              style: TextStyle(
                  fontSize: 16, color: Theme.of(context).colorScheme.onPrimary),
            ),
          ),
          Text(
            widget.time,
            style: TextStyle(
                fontSize: 7, color: Theme.of(context).colorScheme.onSecondary),
          )
        ],
      ),
    );
  }
}

上記のコンポーネントのフィールドは以下

  • message - メッセージ内容
  • sentByme - メッセージの送信者が自分であるかそうでないか
  • time - 送信した時間

上記のコンポーネントは送信者が自分である場合にはmessageを右側に、そうでない場合にはmessageを左側に持ってきます。

そしてそのメッセージの下に投稿した時間が表示されるようにしています。

メッセージを送信する際のfirebaseとのやりとり(Controller)実装

ここではfirebaseにメッセージの登録を実際に行なっていきます。

chat_services.dart
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_timelines/models/message.dart';

class ChatService extends ChangeNotifier {
  // get instance of auth auth and firestore
  final FirebaseAuth _firebaseAuth = FirebaseAuth.instance;
  final FirebaseFirestore _fireStore = FirebaseFirestore.instance;
  // SEND MESSAGE
  Future<void> sendMessage(String receiverId, String message) async {
    //get currentUser info
    final String currentUserId = _firebaseAuth.currentUser!.uid;
    final String currentUserEmail = _firebaseAuth.currentUser!.email.toString();
    final Timestamp timestamp = Timestamp.now();
    // create a new message
    Message newMessage = Message(
      senderId: currentUserId,
      senderEmail: currentUserEmail,
      receiverId: receiverId,
      message: message,
      timestamp: timestamp,
    );
    //construct chat room id from current User id and receiver id (sorted to ensure uniqueness)
    List<String> ids = [currentUserId, receiverId];
    ids.sort(); // sort the ids (this ensures the chat room id is always the same for any pair of people)
    String chatRoomId = ids.join(
        "_"); // combine the ids info a single string to use as a chatroomID

    // add new message to database
    await _fireStore
        .collection('chat_rooms')
        .doc(chatRoomId)
        .collection('messages')
        .add(newMessage.toMap());
  }

  // GET MESSAGES
  Stream<QuerySnapshot> getMessages(String userId, String otherUserId) {
    // construct chat room id from user ids (sorted to ensure it matches the id used when sending )
    List<String> ids = [userId, otherUserId];
    ids.sort();
    String chatRoomId = ids.join("_");
    return _fireStore
        .collection('chat_rooms')
        .doc(chatRoomId)
        .collection("messages")
        .orderBy('timestamp', descending: false)
        .snapshots();
  }
}

上記のコントローラには以下の関数を配置しています。

  • sendMessage - firebaseに送信した内容を登録する
  • getMessage - firebaseに登録している内容を取得する
    それぞれ具体的に説明をします
  1. sendMessage
    前提として登録する内容はmessage.dartで紹介しているフィールドです。
    sendMessageには以下の二つの引数を配置しています。
  • receiverId - 相手のuid
  • message - メッセージ内容
    そしてMessageモデルを使用してメッセージの登録を行います。
Message newMessage = Message(
   senderId: currentUserId,
   senderEmail: currentUserEmail,
   receiverId: receiverId,
   message: message,
   timestamp: timestamp,
);

メッセージの登録作業

// add new message to database
await _fireStore
    .collection('chat_rooms')
    .doc(chatRoomId)
    .collection('messages')
    .add(newMessage.toMap());
  1. getMessage
    chatPageに表示する内容をここで取得します。
List<String> ids = [userId, otherUserId];
ids.sort();

ここで自分のIdと相手のIdを取得します。
そしてsortをして並び替え。

return _fireStore
   .collection('chat_rooms')
   .doc(chatRoomId)
   .collection("messages")
   .orderBy('timestamp', descending: false)
   .snapshots();

ここで一つ一つの投稿内容を取得していきます。

これらの関数は次のchat_page.dartで使用します。

chat_page.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_timelines/helper/helper_methods.dart';
import 'package:flutter_timelines/controller/chat_service.dart';
import 'package:flutter_timelines/view/components/message_tile.dart';
import 'package:flutter_timelines/view/components/chat_text_field.dart';

class ChatPage extends StatefulWidget {
  final String uid;
  final String username;
  const ChatPage({
    Key? key,
    required this.uid,
    required this.username,
  }) : super(key: key);

  
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  final TextEditingController _messageController = TextEditingController();
  final ChatService _chatService = ChatService();
  final FirebaseAuth _firebaseAuth = FirebaseAuth.instance;
  // for textfield focus
  FocusNode myFocusNode = FocusNode();
  
  void initState() {
    super.initState();
    // add listener to focus node
    myFocusNode.addListener(() {
      if (myFocusNode.hasFocus) {
        // cause a delay so that the keyboard has time to show up
        // then the amount of remaining space will be calculated
        //then scroll down
        Future.delayed(const Duration(milliseconds: 500), () => scrollDown());
      }
    });
    // wait a bit for listview to be built, then scroll to bottom
    Future.delayed(const Duration(milliseconds: 50), () => scrollDown());
  }

  
  void dispose() {
    myFocusNode.dispose();
    _messageController.dispose();
    super.dispose();
  }

  // scroll controller
  final ScrollController _scrollController = ScrollController();
  void scrollDown() async {
    _scrollController.animateTo(_scrollController.position.maxScrollExtent,
        duration: const Duration(seconds: 1), curve: Curves.fastOutSlowIn);
  }

  void sendMessage() async {
    // only send message if there is something to send
    if (_messageController.text.isNotEmpty) {
      await _chatService.sendMessage(widget.uid, _messageController.text);
      // clear the text controller after sending the message
      _messageController.clear();
    }
    scrollDown();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.username),
      ),
      body: Column(
        children: [
          //message
          Expanded(
            child: _buildMessageList(),
          ),
          //user input
          _buildMessageInput(),
          const SizedBox(
            height: 30,
          )
        ],
      ),
    );
  }

  // build message list
  Widget _buildMessageList() {
    return StreamBuilder(
      stream: _chatService.getMessages(
        widget.uid,
        _firebaseAuth.currentUser!.uid,
      ),
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Text("Error${snapshot.error}");
        }
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Text("Loading...");
        }
        WidgetsBinding.instance?.addPostFrameCallback((_) => scrollDown());
        return ListView.builder(
          controller: _scrollController,
          itemCount: snapshot.data!.docs.length,
          itemBuilder: (context, index) {
            return _buildmessageItem(snapshot.data!.docs[index]);
          },
        );
      },
    );
  }

  // build message item
  Widget _buildmessageItem(DocumentSnapshot document) {
    Map<String, dynamic> data = document.data() as Map<String, dynamic>;
    return MessageTile(
      message: data["message"],
      sentByMe: data['senderId'] == _firebaseAuth.currentUser!.uid,
      time: chatMessageDate(data['timestamp']),
    );
  }

  // build message input
  Widget _buildMessageInput() {
    return Container(
      width: 370,
      child: Row(
        children: [
          // textfield
          Expanded(
            child: ChatTextField(
              controller: _messageController,
              hintText: '入力',
              obscureText: false,
              focusNode: myFocusNode,
            ),
          ),
          //send button
          IconButton(
            onPressed: sendMessage,
            icon: Container(
              width: 50,
              height: 40,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(20),
                gradient: const LinearGradient(
                  colors: [Colors.deepPurple, Colors.blue], // グラデーションの色のリスト
                  begin: Alignment.centerLeft, // グラデーションの開始位置
                  end: Alignment.centerRight, // グラデーションの終了位置
                ),
              ),
              child: Center(
                child: Icon(
                  Icons.send,
                  size: 25,
                  color: Theme.of(context).colorScheme.primary,
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

上記ではChatをするページの作成をしています。

Discussion