본문 바로가기

Flutter

[Flutter] 채팅앱 만들기 #5

What to do?

Local Message, Local Database 

ⓐ 기기에 저장할 메세지 형태를 Local Message로 정의

ⓑ Sqflite를 사용해 로컬데이터 베이스에 접근하는 코드 작성

ⓒ 테스트 코드 작성


Reference

 


Refactoring

 

기존에 작성했던 파일들을 전부 chat이라는 폴더로 때려넣음

 

 

chat/lib/chat.dart

 

기작성한 모델과 서비스 코드를 export하는 파일 작성

(interface는 export하지 않음)

library chat;

export './model/receipt_model.dart';
export './model/message_model.dart';
export './model/typing_event_model.dart';
export './model/user_model.dart';
export './service/encryption/encryption_service.dart';
export './service/message/message_service.dart';
export './service/receipt/receipt_service.dart';
export './service/typing/typing_notification_service.dart';
export './service/user/user_service.dart';

chat/lib/chat.dart

 

 

chat/pubspec.yaml

 

dependency 수정

  • rethink_db에서 rethink_db_ns로 수정
  • → service 코드에서 import 하는 부분 전부 수정
name: chat
description: A new Flutter package project.
version: 0.0.1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  encrypt: ^5.0.1
  logger: ^1.0.2
  flutter:
    sdk: flutter
  rethink_db_ns: ^0.0.4

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.3.2

flutter: null

chat/pubspec.yaml

 

pubspec.yaml

 

pubspec.yaml 파일에서 chat 라이브러리를 사용할 수 있도록 설정

dependencies:
  flutter:
    sdk: flutter
  chat:
    path: ./chat
  cupertino_icons: ^1.0.5
  sqflite: ^2.2.5

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.3.2

pubspec.yaml


Local Message Model

 

기기(로컬 데이터베이스)에 저장된 메세지를 정의하기 위해 Lcoal Messsage라는 모델을 정의

  • chatId : 채팅방 아이디 → 메세지 보낸 유저의 아이디
  • message : Message 객체
  • receiptStatus : 메세지를 읽었는지 여부
    • sent : 내가 보낸 메세지
    • delivered : 전달되었지만 아지 읽지 않은 메세지
    • read : 이미 읽은 메세지

 

/model/local_message_model.dart

import 'package:chat/chat.dart';

class LocalMessage {
  String _id;

  String get id => _id;
  String chatId;
  Message message;
  ReceiptStatus status;

  LocalMessage(this.chatId, this.message, this.status);

  Map<String, dynamic> toMap() => {
        'chat_id': chatId,
        'id': message.id,
        'status': status.value(),
        ...message.toJson()
      };

  factory LocalMessage.fromMap(Map<String, dynamic> json) {
    final message = Message(
        from: json['from'],
        to: json['to'],
        timestamp: json['timestamp'],
        contents: json['contents']);
    final localMessage =
        LocalMessage(json['chat_id'], message, EnumParsing.fromString(json['status']));
    localMessage._id = json['id'];
    return localMessage;
  }
}

 

└ EnumParsing.fromString이라는 코드는 이전 포스팅에서 ReceiptStatus를 enum으로 정의할 때 작성한 class


Chat Model

 

카톡 채팅방을 생각해보면 △ 읽지 않은 메세지 수 가장 최근 메세지를 보여준다.

카카오톡 예시

이와 유사하게, 다음과 같은 필드를 정의

  • id : 메세지를 보낸 유저의 id
  • unread : 아직 읽지 않은 메세지 개수
  • messages : 메세지 list
  • mostRecent : 가장 최근에 온 메세지

 

model/chat_model.dart

import 'local_message_model.dart';

class Chat {
  String id;
  int unread = 0;
  List<LocalMessage> messages = [];
  LocalMessage mostRecent;

  Chat(this.id, {this.messages, this.mostRecent});

  toMap() => {'id': id};

  factory Chat.fromMap(Map<String, dynamic> json) => Chat(json['id']);
}

DataSource Interface

 

datasource 구현에 앞서, interface를 정의

채팅서비스에 필요한 CRUD 메써드 정의

  • Create
    • 채팅방 생성
    • 메세지 생성
  • Read
    • 특정 채팅방 조회
    • 전체 채팅방 조회
  • Update
    • 메세지 수정
  • Delete
    • 메세지 삭제
import 'package:flutter_prj/model/chat_model.dart';
import 'package:flutter_prj/model/local_message_model.dart';

abstract class IDataSource {
  /// create
  Future<void> addChat(Chat chat);

  Future<void> addMessage(LocalMessage message);

  /// read
  Future<Chat> findChat(String chatId);

  Future<List<Chat>> findAllChat();

  Future<List<LocalMessage>> findMessages(String chatId);

  /// update
  Future<void> updateMessage(LocalMessage message);

  /// delete
  Future<void> deleteChat(String chatId);
}

DataSource Service

 

datasource 코드의 일부 메서드에서는 rawQuery를 사용해 데이터를 가져온다.

 

  • 각 채팅방에서 읽지 않은 메세지 수를 구하는 쿼리
SELECT CHAT_ID, COUNT(*) AS UNREAD FROM MESSAGES
WHERE RECEIPT_STATUS = DELIVERED
GROUP BY CHAT_ID

 

  • 각 채팅방의 가장 최근 메세지를 가져오는 쿼리
SELECT MESSAGES.* FROM (
  SELECT CHAT_ID, MAX(CREATED_AT) AS CREATED_AT 
  FROM MESSAGES
  GROUP BY CHAT_ID
) AS LATEST_MESSAGES 
INNER JOIN MESSAGES
ON MESSAGES.CHAT_ID = LATEST_MESSAGES.CHAT_ID
AND MESSAGE.CREATED_AT = LATEST_MESSAGES.CREATED_AT

 

datasource/sqflite_datasource.dart

import 'package:flutter_prj/data/datasource/datasource_interface.dart';
import 'package:flutter_prj/model/chat_model.dart';
import 'package:flutter_prj/model/local_message_model.dart';
import 'package:sqflite/sqflite.dart';

class SqfliteDataSource implements IDataSource {
  final Database _db;

  const SqfliteDataSource(this._db);

  final queryForUnread = '''
    SELECT CHAT_ID, COUNT(*) AS UNREAD FROM MESSAGES
    WHERE RECEIPT_STATUS = ?
    GROUP BY CHAT_ID
     ''';
  final queryForLatestMessage = '''
      SELECT MESSAGES.* FROM (
        SELECT CHAT_ID, MAX(CREATED_AT) AS CREATED_AT 
        FROM MESSAGES
        GROUP BY CHAT_ID
      ) AS LATEST_MESSAGES 
      INNER JOIN MESSAGES
      ON MESSAGES.CHAT_ID = LATEST_MESSAGES.CHAT_ID
      AND MESSAGE.CREATED_AT = LATEST_MESSAGES.CREATED_AT
      ''';

  @override
  Future<void> addChat(Chat chat) async {
    await _db.insert('chats', chat.toMap(),
        conflictAlgorithm: ConflictAlgorithm.replace);
  }

  @override
  Future<void> addMessage(LocalMessage message) async {
    await _db.insert('messages', message.toMap(),
        conflictAlgorithm: ConflictAlgorithm.replace);
  }

  @override
  Future<List<Chat>> findAllChat(String chatId) {
    return _db.transaction((txn) async {
      final chatsWithLatestMessage = await txn.rawQuery(queryForLatestMessage);
      if (chatsWithLatestMessage.isEmpty) return [];
      final chatWithUnreadMessages =
          await txn.rawQuery(queryForUnread, ['delivered']);
      return chatsWithLatestMessage.map<Chat>((row) {
        final int unread = int.tryParse(chatWithUnreadMessages.firstWhere(
            (element) => (row['chat_id'] == element['chat_id']),
            orElse: () => {'unread': 0})['unread']);
        final chat = Chat.fromMap(row);
        chat.unread = unread;
        chat.mostRecent = LocalMessage.fromMap(row);
        return chat;
      }).toList();
    });
  }

  @override
  Future<Chat> findChat(String chatId) async {
    return await _db.transaction((txn) async {
      final listOfChatMaps =
          await txn.query('chat', where: 'CHAT_ID = ?', whereArgs: [chatId]);
      if (listOfChatMaps.isNotEmpty) return null;
      final unread = Sqflite.firstIntValue(
          await txn.rawQuery(queryForUnread, [chatId, 'delivered']));
      final mostRecentMessage = await txn.query('messages',
          where: 'CHAT_ID = ?',
          whereArgs: [chatId],
          orderBy: 'CREATED_AT DESC',
          limit: 1);
      final chat = Chat.fromMap(listOfChatMaps.first);
      chat.unread = unread;
      chat.mostRecent = LocalMessage.fromMap(mostRecentMessage.first);
      return chat;
    });
  }

  @override
  Future<List<LocalMessage>> findMessages() async {
    final listOfMaps =
        await _db.query('messages', where: 'CHAT_ID = ?', whereArgs: [chatId]);
    return listOfMaps
        .map<LocalMessage>((map) => LocalMessage.fromMap(map))
        .toList();
  }

  @override
  Future<void> updateMessage(LocalMessage localMessage) async {
    _db.update('messages', localMessage.toMap(),
        where: 'ID = ?',
        whereArgs: [localMessage.message.id],
        conflictAlgorithm: ConflictAlgorithm.replace);
  }

  @override
  Future<void> deleteChat(String chatId) async {
    final batch = _db.batch();
    batch.delete('messages', where: 'CHAT_ID = ?', whereArgs: [chatId]);
    batch.delete('chats', where: 'ID = ?', whereArgs: [chatId]);
    await batch.commit(noResult: true);
  }
}

테스트 코드 설명

 

데이터베이스와 배치를 Mocking하기 위해 Mockito 라이브러리를 사용해 테스트코드 작성

 

test/sqflite_datasource_test.dart

class MockSqfliteDatabase extends Mock implements Database {}

class MockBatch extends Mock implements Batch {}

void main() {
  SqfliteDataSource sut;
  MockSqfliteDatabase db;
  MockBatch batch;

  setUp(() {
    db = MockSqfliteDatabase();
    batch = MockBatch();
    sut = SqfliteDataSource(db);
  });

 
  test코드...

}

 

 

  • 채팅방 추가
    • chats 테이블에 레코드 추가

서비스코드

@override
Future<void> addChat(Chat chat) async {
  await _db.insert('chats', chat.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace);
}

테스트코드

test('when insert a chat data into chat table successfully, then return 1',
    () async {
  final chat = Chat('1234');
  when(db.insert('chat', chat.toMap(),
          conflictAlgorithm: ConflictAlgorithm.replace))
      .thenAnswer((_) async => 1);
  await sut.addChat(chat);
  verify(db.insert('chats', chat.toMap(),
          conflictAlgorithm: ConflictAlgorithm.replace))
      .called(1);
});

 

  • 메세지 추가
    • addMessage : 로컬 메세지를 받아서, messages 테이블에 레코드 추가

 

서비스 코드

@override
Future<void> addMessage(LocalMessage message) async {
  await _db.insert('messages', message.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace);
}

테스트코드

test(
    'when insert a local message into message table successfully, then return 1',
    () async {
  final localMessage = LocalMessage('1234', message, ReceiptStatus.delivered);
  when(db.insert('messages', localMessage.toMap(),
          conflictAlgorithm: ConflictAlgorithm.replace))
      .thenAnswer((_) async => 1);
  await sut.addMessage(localMessage);
  verify(db.insert('messages', localMessage.toMap(),
          conflictAlgorithm: ConflictAlgorithm.replace))
      .called(1);
});

 

  • 메세지 조회
    • findMessages : 채팅방 아이디를 전달받아서, 메세지들을 list 반환

서비스코드

@override
Future<List<LocalMessage>> findMessages(String chatId) async {
  final listOfMaps =
      await _db.query('messages', where: 'CHAT_ID = ?', whereArgs: [chatId]);
  return listOfMaps
      .map<LocalMessage>((map) => LocalMessage.fromMap(map))
      .toList();
}

테스트코드

  test('find message by chat id', () async {
    //arrange
    final messagesMap = [
      {
        'chat_id': '111',
        'id': '4444',
        'from': '111',
        'to': '222',
        'contents': 'hey',
        'status': 'sent',
        'timestamp': DateTime.parse("2021-04-01"),
      }
    ];
    when(db.query(
      'messages',
      where: anyNamed('where'),
      whereArgs: anyNamed('whereArgs'),
    )).thenAnswer((_) async => messagesMap);

    //act
    var messages = await sut.findMessages('111');

    //assert
    expect(messages.length, 1);
    expect(messages.first.chatId, '111');
    verify(db.query(
      'messages',
      where: anyNamed('where'),
      whereArgs: anyNamed('whereArgs'),
    )).called(1);
  });

'Flutter' 카테고리의 다른 글

[Flutter] 채팅앱 만들기 #7  (0) 2023.03.05
[Flutter] 채팅앱 만들기 #6  (0) 2023.03.05
[Flutter] 채팅앱 만들기 #4  (0) 2023.03.01
[Flutter] 채팅앱 만들기 #3  (0) 2023.03.01
[Flutter] 채팅앱 만들기 #2  (0) 2023.03.01