Home Flutter 01 Simple Chat UI 응용 03 데이터 로컬 저장 및 읽기,쓰기_메세지 로그편
Post
Cancel

Flutter 01 Simple Chat UI 응용 03 데이터 로컬 저장 및 읽기,쓰기_메세지 로그편

이 포스트는

을 따라서 작성한 Flutter Simple Chat UI 을 응용하여 좀 더 그럴듯한 채팅앱을 구현하기 위한 과정을 다룬다.

주요 내용은 이하와 같다.

  • ChatDetailPage : 유저 별 메세지 로그 저장, 불러오기
    • 코드 정리 : 메세지 로그 리스트뷰 코드 분리
    • 메세지 로그 저장/불러오기 시의 주의 점
    • 해당 유저의 메세지 로그 불러오기
    • 메세지 로그 저장
    • UI 변경, 기능 추가 (옵션)
      • 받은 메세지 조작 : receiver 버튼, 기능 추가
      • 메세지 초기화 : clear 버튼, 기능 추가
      • 개별 메세지에 대한 기능 추가 (개별 메세지 복사, 수정, 삭제)
  • ChatPage : 유저 데이터 갱신
    • 각 유저의 최근 메세지 표시

원문의 예제는 단순히 UI만 다루는 포스트이다 보니, 실제로 데이터를 전달하거나 저장하는 등 데이터를 처리하는 작업은 해주지 않는다. 그러므로 이번 포스트는 스스로 공부하며 추가한 코드이다.

관련 포스트


환경

  • MacBook Air (M1, 2020)
  • macOS Monterey (v12.4)

  • Flutter 3.0.3
  • Dart 2.17.5

  • Visual Studio Code 1.69.0

ChatDetailPage : 유저 별 메세지 로그 저장, 불러오기

먼저 유저 별 메세지 로그를 저장하고, 기타 기능을 구현하기 위해서 ChatMessage 모델의 속성을 조금 추가해 주었다.

  • userid : 현재 채팅을 하고있는 유저의 id속성을 받는 속성
  • messageid : 개별 메세지의 식별을 위한 속성, ChatUsers 모델의 id와 마찬가지로 uuid플러그인을 이용해 생성자 실행 시에 자동으로 할당되도록 해주었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import 'package:hive/hive.dart'; 
import 'package:uuid/uuid.dart';

part 'chatMessageModel.g.dart';

var uuid = Uuid();

@HiveType(typeId: 2)
class ChatMessage {
  @HiveField(0) //추가
  String userid; //추가

  @HiveField(1) //추가
  String messageid; //추가

  @HiveField(2) 
  String messageContent;

  @HiveField(3) 
  String messageType;


  ChatMessage(
      {required this.userid,
      String? messageid,
      required this.messageContent,
      required this.messageType})
      : messageid = messageid ?? uuid.v4();

}

수정 후에는 TypeAdapter 갱신해주는 걸 잊지 말자

1
flutter packages pub run build_runner build

코드 정리 : 메세지 로그 리스트뷰 코드 분리

지금 ChatDetailPage.dart 화면에 UI랑 데이터 처리 코드가 섞여 너무 정신사나워서, 메세지 로그를 표시하는 리스트뷰 부분 코드를 조금 정리했다.

chatPage.dart의 유저 목록을 표시하는 리스트뷰 conversationList.dart를 분리한 것 처럼, ChatDetailPage.dart도 body에 메세지로그를 표시하는 Listview를 messageList.dart 라는 widget으로 분리했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import 'package:flutter/material.dart';

class MessageList extends StatefulWidget {
  String userid;
  ChatMessage currentmsg;

  MessageList(
      {required this.userid,
      required this.currentmsg});

  @override
  _MessageListState createState() => _MessageListState();
}

class _MessageListState extends State<MessageList> {
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.only(left: 16, right: 16, top: 10, bottom: 10),
      child: Align(
        alignment: (widget.currentmsg.messageType == "receiver"
            ? Alignment.topLeft
            : Alignment.topRight),
        child: Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(20),
            color: (widget.currentmsg.messageType == "receiver"
                ? Colors.grey.shade200
                : Colors.blue[200]),
          ),
          padding: EdgeInsets.all(16),
          margin: (widget.currentmsg.messageType == "receiver"
              ? EdgeInsets.only(left: 0, right: 64, top: 0, bottom: 0)
              : EdgeInsets.only(left: 64, right: 0, top: 0, bottom: 0)),
          child: Text(¥
            widget.currentmsg.messageContent,
            style: TextStyle(fontSize: 15),
          ),
        ),
      ),
    );
  }
}

기존 파일에서는 해당 부분 삭제하고 인수 전달만 해주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//추가
import 'package:flt_simple_chat_ex/widgets/messageList.dart';

... // 생략
      body: Column(
        children: <Widget>[
          Flexible(
            fit: FlexFit.tight, 
            child: SingleChildScrollView(
              physics: BouncingScrollPhysics(), 
              controller: _scrollController, 
              child: ListView.builder(
                itemCount: _mstate_currentuser.length,
                shrinkWrap: true,
                padding: EdgeInsets.only(
                    top: 10), 
                physics: NeverScrollableScrollPhysics(),
                itemBuilder: (context, index) {
                  // 수정, 인수로 전달
                  return MessageList( 
                    userid: widget.cpuser.id,
                    currentmsg: ChatMessage(
                        userid: widget.cpuser.id,
                        messageid: _mstate_currentuser[index].messageid,
                        messageContent:
                            _mstate_currentuser[index].messageContent,
                        messageType: _mstate_currentuser[index].messageType),
                  );
                  // 수정, 인수로 전달
                },
              ),
            ),
          ),
... // 생략

이제 메세지 로그에 대한 처리는 messageList.dart에서 할 것이다.

메세지 로그 저장/불러오기 시의 주의 점

코드도 정리했고 사용할 기능도 대충 만들었으니 이제 본격적으로 메세지를 저장해보자. 근데 사실… 유저 리스트 저장하는 것과 별 다를 것 없다. (이전 포스팅에서 provider에 대한 걸 다뤘으니 당연히 /providers/messages.dart가 있고, TypeAdapter가 등록, 범위 지정이 되어있다고 가정한다.) 메세지 로그를 담은 박스의 이름은 'message_log'인데, provider에서 이 박스를 요리 다루고 조리 다루면 된다. 메세지에 대한 읽기 및 쓰기 작업이 이루어 지는 위젯은 주로 chatDetailPage.dartmessageList.dart일텐데, 필요에 따라

1
2
3
4
5
6
7
8
import 'package:flt_simple_chat_ex/providers/messages.dart';
import 'package:provider/provider.dart';
... // 생략
  @override
  Widget build(BuildContext context) {
    final _mstate = context.watch<MessageListPrState>().messages;
    final _mctlr = context.read<MessageListController>();
... // 생략

이런 식으로 provider에 접근해서 메세지를 읽고 써주면 된다.

단, 지금 구현한 코드에서 유저 목록을 다룰 때와는 다르게 반드시 고려해야 할 점이 있는데, 바로 유저 목록('user_log'박스)은 그 박스 자체로 하나의 개체로 볼 수 있지만, 메세지 로그('message_log'박스)의 경우 모든 유저의 메세지를 구별없이 전부 담고 있는 하나의 혼돈의 박스라는 점이다.
그러니까 각 유저의 채팅 창에는 'message_log'에 있는 모든 메세지를 전부 출력할게 아니라 그 채팅 창의 유저가 누군지 확인해서 해당 유저와 관련된 메세지만을 표시해야 된다는 소리다. 이걸 위해 ChatMessage 모델에 userid 속성을 추가한 것이다. (나도… 아주 이상한 코드라는 건 안다… 알긴 아는데 능력 부족으로 일단 밀고 나갔다)

개판 5분 전이지만 일단 가보자고…

해당 유저의 메세지 로그 불러오기

채팅창을 열었을 때, checkUserMsg(widget.cpuser.id) 라는 함수를 사용하여 리스트뷰를 빌드했다.
messages provider의 MessageListPrStatecheckUserMsg라는 함수를 만들어서 현재 열어둔 채팅창의 userid를 가진 메세지들만으로만 구성된 리스트를 반환하도록 해주었다. (MessageListPrState에 구현한 이유는, 딱히 데이터베이스에 항목을 추가하거나 삭제하는게 아니고 단지 선별해서 읽어올 뿐이기 때문)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
... // 생략
    final _mstate_currentuser =
        context.watch<MessageListPrState>().checkUserMsg(widget.cpuser.id);
... // 생략
      body: Column(
        children: <Widget>[
          Flexible(
            fit: FlexFit.tight,
            child: SingleChildScrollView(
              physics: BouncingScrollPhysics(),
              controller: _scrollController,
              child: ListView.builder(
                itemCount: _mstate_currentuser.length, // 수정
                shrinkWrap: true,
                padding: EdgeInsets.only(
                    top: 10),
                physics: NeverScrollableScrollPhysics(),
                itemBuilder: (context, index) {
                  return MessageList(
                    userid: widget.cpuser.id,
                    currentmsg: ChatMessage(
                        userid: widget.cpuser.id,
                        messageid: _mstate_currentuser[index].messageid, // 수정
                        messageContent:
                            _mstate_currentuser[index].messageContent, // 수정
                        messageType: _mstate_currentuser[index].messageType), // 수정
                  );
                },
              ),
            ),
          ), // Flexible
... // 생략

checkUserMsg()는 이렇게 생겼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
... // 생략
class MessageListPrState {
... // 생략
  List<ChatMessage> checkUserMsg(String userid) {
    List<ChatMessage> usermsg = [];
    for (int i = 0; i < messages.length; i++) {
      if (messages[i].userid == userid) {
        usermsg.insert(usermsg.length, messages[i]);
      }
    }
    return usermsg;
  }
... // 생략

이렇게하면 최적화는 개나 준 코드 완성!

유저가 생길 때마다 메세지 박스를 열어 준 다음, ChatDetailPage에 접근하면 그 박스를 던져주고 연산 없이 읽고 쓸 수 있게 해줘야 한다… 는 걸 머리로는 알고 있지만, 그냥 코드가 생겨 먹은 대로 쓰려고 짱구를 굴리다 보니 이런 요오상한 코드가 탄생했다.

사실 그렇게 해주려고 이것저것 찾아보고 provider안에서 openBox해주도록 짜봤는데, provider의 Scope를 거의 최상위 위젯으로 두고 있어서, openBox하는 타이밍이 main()이 아니면 에러가… ㅎ…
원하는 기능이 그럴 듯하게 구현이 되기는 했지만, 이건 너무 눈가리고 아웅하는 식이라 hive를 좀 더 동적으로 다룰 수 있는 방법이 있나 찾아보고, 시도해 볼 예정이다. 나의 실력에 RIP,,,

메세지 로그 저장

ChatDetailPage에서 send 버튼을 누를 때, addMessage(newMsg) 라는 함수를 실행

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
... // 생략
                    SizedBox(
                      height: 30,
                      width: 50,
                      child: FloatingActionButton(
                        heroTag: "btn_send", 
                        onPressed: () {
                          if (msgtextController.text.isNotEmpty) {
                            ChatMessage newMsg = ChatMessage(
                                userid: widget.cpuser.id,
                                messageContent: msgtextController.text,
                                messageType: "sender");
                            debugPrint('Input text : ${newMsg.messageContent}');
                            setState(() {
                              _mctlr.addMessage(newMsg); // 추가
                              msgtextController.clear();
                              latest_msg = msgtextController.text;
                            });
                            _scrollToLatest();
                          }
                        },
                        child: Icon(
                          Icons.send,
                          color: Colors.white,
                          size: 18,
                        ),
                        backgroundColor: Colors.blue,
                        elevation: 0,
                      ),
                    ),
... // 생략

messages provider MessageListControlleraddMessage함수는 이렇게 생겼다. (이건 그냥 addUser와 똑같이 생겼음)

1
2
3
4
5
6
7
... // 생략
  void addMessage(ChatMessage message) {
    final _messages = [...state.messages, message];
    state = state.copyWith(messages: _messages);
    messageList!.put(message.messageid, message);
  }
... // 생략

기본적인 읽기, 쓰기기는 이렇다. 여기까지 했으면, (비록 효율적이지는 않지만) 유저 별로 메세지의 저장이 가능한 기본적인 기능을 하는 것처럼 보이는 채팅앱이 완성된다.

Flutter Simple chat UI ex 03-04

(데모에서는 ChatPage로 돌아갔을 때, 유저 이름 밑에 유저와의 마지막 메세지가 표시되도록 하고 있는데, 이에 대해서는 이 포스팅 마지막 장에 기술)

UI 변경, 기능 추가 (옵션)

이번 장은 그냥 나 편하자고 이하의 UI 변경, 기능 추가를 한 내용이다.

  • 받은 메세지 조작 : receiver 버튼, 기능 추가
  • 메세지 초기화 : clear 버튼, 기능 추가
  • 개별 메세지에 대한 기능 추가 (개별 메세지 복사, 수정, 삭제)

받은 메세지 조작 : receiver 버튼, 기능 추가

원래는 내가 보내는 버튼만 있는게 맞지만, 여기서는 그럴듯하게 채팅창을 모방하기 위해 내가 받는 메세지도 조작할 수 있도록 send버튼 왼쪽 옆에 receive버튼을 하나 더 추가해 주었다. 텍스트 필드에 글을 입력하고 receive버튼을 누르면 해당 메세지가 나에게 온 메세지처럼 표시하는 기능을 한다.

기본적으로는 send버튼의 포멧을 그대로 사용하고, 전달하는 messageTypereceiver 해주면 된다. (+ Icon 변경)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
... // 생략
                    SizedBox(
                      height: 30,
                      width: 50,
                      child: FloatingActionButton(
                        heroTag: "btn_receive", //heroTag 중복에러 방지 (heroTag: null도 가능)
                        onPressed: () {
                          if (msgtextController.text.isNotEmpty) {
                            ChatMessage newMsg = ChatMessage(
                                userid: widget.cpuser.id,
                                messageContent: msgtextController.text,
                                messageType: "receiver"); //수정
                            setState(() {
                              _mctlr.addMessage(newMsg);
                              msgtextController
                                  .clear(); 
                              latest_msg = msgtextController.text;
                            });
                            _scrollToLatest(); 
                          }
                        },
                        child: Icon(
                          Icons.send_outlined,
                          color: Colors.white,
                          size: 18,
                        ),
                        backgroundColor: Colors.blue,
                        elevation: 0,
                      ),
                    ),
... // 생략

만약 receiver버튼 추가하고 이런 에러가 난다면,

════════ Exception caught by scheduler library ═════════════════════════════════
The following assertion was thrown during a scheduler callback:
There are multiple heroes that share the same tag within a subtree.

Within each subtree for which heroes are to be animated (i.e. a PageRoute subtree), each Hero must have a unique non-null tag.
In this case, multiple heroes had the following tag: <default FloatingActionButton tag>
Here is the subtree for one of the offending heroes: Hero
    tag: <default FloatingActionButton tag>
    state: _HeroState#a0c4c

heroTag를 설정하지 않은 FloatingActionButton이 같은 위젯에 여러개 있어서 그런 것이니, 각 FloatingActionButton위젯에 herotag를 설정해 주자. (모든 FloatingActionButtonheroTag: null이라고 해줘도 되긴 됨)

결과, 이러한 UI 된다. 왼쪽 버튼을 누르면 내가 받은 메세지가 되고, 오른쪽 버튼을 누르면 내가 보낸 메세지가 된다.

Flutter Simple chat UI ex 03-01

메세지 초기화 : clear 버튼, 기능 추가

ChatPage의 유저 목록 Clear와 마찬가지로 AppBar에 메세지 로그의 모든 메세지을 지우는 버튼을 추가했다. (+덤으로 설정 버튼이 그냥 Icon으로만 되어있길래 누를 수 있도록 IconButton으로 변경해 주었음, 기능은 추가X)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
... // 생략
    final _mctlr = context.read<MessageListController>();
... // 생략
                IconButton( // 메세지 로그 Clear
                  onPressed: () {
                    print("Click Clear Button");
                    _mctlr.clearUserMsg(widget.cpuser.id); //추가
                  },
                  alignment: Alignment.centerRight,
                  icon: const Icon(
                    Icons.clear_all,
                    color: Colors.black,
                  ),
                ),
                IconButton( // Setting
                  onPressed: () {
                    print("Click Setting Button");
                  },
                  alignment: Alignment.centerRight,
                  icon: const Icon(
                    Icons.settings,
                    color: Colors.black54,
                  ),
                ),
... // 생략

추가 후의 UI는 이런 모습.

Flutter Simple chat UI ex 03-02

기능 구현을 위해서 message provider(/providers/messages.dart)의 MessageListControllerclearUserMsg 라는 함수를 추가해 주었다. 동작방식은 userid를 인수로 받아, 기존의 메세지 리스트(messages)에서 userid를 가진 메세지를 솎아낸 메세지 리스트를 만들어(_messages), 기존의 메세지 리스트(messages)를 해당 메세지 리스트(_messages)로 교체하는 것이다.
hive box에서의 삭제는 좀 더 쉽다. .delete함수를 사용하여 userid를 가진 개체를 지워주면 된다.

1
2
3
4
5
6
7
8
9
... // 생략

  void clearUserMsg(String userid) {
    final _messages = state.messages.where((m) => m.userid != userid).toList();

    state = state.copyWith(messages: _messages);
    messageList!.delete(userid);
  }
... // 생략

위의 두 기능에 대한 데모. 잘 구현되었다는 걸 확인할 수 있다.

Flutter Simple chat UI ex 03-03

개별 메세지에 대한 기능 추가 (개별 메세지 복사, 수정, 삭제)

개별 메세지를 롱터치하면 메뉴가 나오고, 메뉴를 통해 해당 메세지를 복사, 수정, 삭제가 가능하도록 기능을 추가했다.

메세지를 롱터치 했을 때, 메뉴가 나오게 하는 방법은 Flutter: Showing a Context Menu on Long Pressstackoverflow의 코드를 참고했다.

사실 두 사이트에 있는 솔루션 그대로 코드를 작성하면 내가 원하는 대로 움직이지를 않길래 (메뉴 박스가 롱터치한 위치에서 제대로 나오지 않고 x축 또는 y축이 고정되어있었음.. 반쪽짜리 기능 됨) 입맛대로 고쳐서 원하는 대로 움직이게 만들긴 했는데, 얼레벌레 성공해 버려서 솔직히 RelativeRect 클래스의 동작 방식을 아직 완전히 이해하지는 못했다. 두 사각형을 그려서 상대적인 위치를 계산해 박스를 출력하는것 같긴한데… 나중에 문서 좀 제대로 읽어봐야 겠다. 일단 내가 작성한 얼레벌레 코드는 이렇다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
... // 생략
class _MessageListState extends State<MessageList> {
... // 생략
  Offset _tapPosition = Offset.zero;

  void _getTapPosition(TapDownDetails details) {
    print("on Tap Down");
    final RenderBox referenceBox = context.findRenderObject() as RenderBox;

    setState(() {
      _tapPosition = referenceBox.globalToLocal(details.globalPosition);
    });
  }

  final widgetKey = GlobalKey();

  RelativeRect _getRelativeRect(GlobalKey key) {
    return RelativeRect.fromSize(
        _getWidgetGlobalRect(key), const Size(10000, 200));
  }

  Rect _getWidgetGlobalRect(GlobalKey key) {
    final RenderBox renderBox =
        key.currentContext!.findRenderObject() as RenderBox;
    var offset = renderBox.localToGlobal(Offset.zero);

    final RenderObject? overlay =
        Overlay.of(context)?.context.findRenderObject();

    return Rect.fromLTWH(_tapPosition.dx, offset.dy, renderBox.size.width,
        renderBox.size.height);
  }

  void _showContextMenu(BuildContext context, GlobalKey key) async {
    final _mctlr = context.read<MessageListController>(); // 동작 제어를 위해 미리 추가

    final result = await showMenu(
        context: context,
        position: _getRelativeRect(key),
        items: [
          const PopupMenuItem(
            value: 'copy',
            child: Text('Copy Text'),
          ),
          const PopupMenuItem(
            value: 'edit',
            child: Text('Edit Text'),
          ),
          const PopupMenuItem(
            value: 'delete',
            child: Text('Delete Text'),
          ),
        ]);

    switch (result) {
      case 'copy':
        debugPrint('Copy Text');
        // 복사 동작 추가 부분
        break;
      case 'edit':
        debugPrint('Edit Text');
        // 수정 동작 추가 부분
        break;
      case 'delete':
        debugPrint('Delete Text');
        // 삭제 동작 추가 부분
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // 추가
      key: widgetKey,
      onTapDown: (details) {
        _getTapPosition(details); 
      },
      onLongPress: () {
        _showContextMenu(context, widgetKey);
      },
      // 추가 END
      child: Container(
... // 생략

복사

services.dart 라는 패키지의 Clipboard.setData()라는 함수를 통해서 쉽게 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
... // 생략
import 'package:flutter/services.dart'; // 추가
... // 생략
  void _showContextMenu(BuildContext context) async {
... // 생략
    switch (result) {
      case 'copy':
        debugPrint('Copy Text');
        Clipboard.setData(ClipboardData(text: widget.currentmsg.messageContent)); // 추가
        break;
... // 생략

수정

먼저 message provider MessageListControllereditMessage()라는 함수를 작성해 주었다. 버튼이 눌리면 텍스트를 수정할 수 있는 팝업을 실행(InputDialog), 팝업창에서 텍스트의 수정이 발생한 경우, editMessage()함수를 실행해 주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
... // 생략
  void _showContextMenu(BuildContext context) async {
    final _mctlr = context.read<MessageListController>();
... // 생략
    switch (result) {
... // 생략
      case 'edit':
        debugPrint('Edit Text');
        String? inputtext = await InputDialog(context);
        _textFieldController.clear();

        if (inputtext == "" ||
            inputtext == null ||
            inputtext == widget.currentmsg.messageContent) {
          print("Text not changed!");
        } else {
          String edittext = inputtext;
          _mctlr.editMessage(widget.currentmsg, edittext);
          setState(() {
            widget.currentmsg.messageContent = edittext; //화면에서도 곧 바로 수정
          });
        }
        break;
... // 생략

InputDialog()함수는 별다를 것 없이 생겼는데, 이번에는 기존의 텍스트를 수정하는 거니까 텍스트 필드에 기존에 메세지 내용이 디폴트로 들어가 있고 그것을 수정하도록 TextEditingController의 포멧을 조금 변경해 주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
... // 생략
class _MessageListState extends State<MessageList> {
  late TextEditingController _textFieldController;

  @override
  void initState() {
    super.initState();
    _textFieldController = TextEditingController(
        text: widget.currentmsg.messageContent); // 기존 텍스트가 텍스트필드의 초기값
  }

  Future<String?> InputDialog(BuildContext context) async {
    final ButtonStyle flatButtonStyle = TextButton.styleFrom(
      backgroundColor: Color.fromARGB(255, 100, 100, 9100),
      padding: EdgeInsets.all(0),
    );
    _textFieldController = TextEditingController(
        text: widget.currentmsg.messageContent); // 기존 텍스트가 텍스트필드의 초기값

    return showDialog(
        context: context,
        builder: (context) {
          return AlertDialog(
            title: Text('메세지 수정'),
            content: TextField(
              controller: _textFieldController,
              decoration:
                  InputDecoration(hintText: widget.currentmsg.messageContent),
            ),
            actions: <Widget>[
              ElevatedButton(
                child: const Text("취소"),
                onPressed: () => Navigator.pop(context),
              ),
              ElevatedButton(
                child: const Text('완료'),
                onPressed: () =>
                    Navigator.pop(context, _textFieldController.text),
              ),
            ],
          );
        });
  }
... // 생략

editMessage()에서는 messageid속성을 이용하여 해당 메세지를 식별하여 해당 메세지의 messageContent를 입력 받은 텍스트로 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
... // 생략
class MessageListController extends StateNotifier<MessageListPrState> {
... // 생략
  void editMessage(ChatMessage message, String edittext) {
    for (int index = 0; index < [...state.messages].length; index++) {
      if ([...state.messages][index].messageid == message.messageid) {
        [...state.messages][index].messageContent = edittext;
        messageList!.put(
            message.messageid,
            ChatMessage(
                userid: message.userid,
                messageid: message.messageid,
                messageContent: edittext,
                messageType: message.messageType));
        break;
      } else {
        continue;
      }
    }
  }

... // 생략

삭제

message provider MessageListControllerdeleteMessage()라는 함수를 작성해 주고, 버튼이 눌리면 함수를 실행해 주었다.

1
2
3
4
5
6
7
8
9
10
11
... // 생략
  void _showContextMenu(BuildContext context) async {
    final _mctlr = context.read<MessageListController>();
... // 생략
    switch (result) {
... // 생략
      case 'delete':
        debugPrint('Delete Text');
        _mctlr.deleteMessage(widget.currentmsg);
        break;
... // 생략

deleteMessage()에서는 messageid속성을 이용하여 해당 메세지만 배제한 메세지 리스트를 만들어낸다.

1
2
3
4
5
6
7
8
9
10
11
... // 생략
class MessageListController extends StateNotifier<MessageListPrState> {
... // 생략
  void deleteMessage(ChatMessage message) {
    final _messages =
        state.messages.where((m) => m.messageid != message.messageid).toList();

    state = state.copyWith(messages: _messages);
    messageList!.delete(message.userid);
  }
... // 생략

이 기능들의 실행 데모는 다음과 같다.

Flutter Simple chat UI ex 03-05

ChatPage : 유저 데이터 갱신

각 유저의 최근 메세지 표시

이렇게 유저별 채팅 데이터를 저장해 주면, ChatPage화면에 출력 되는 각 유저의 최근메세지를 갱신하는 의미가 생긴다. 유저 리스트 UI를 표시하는 conversationList.dart에서 유저 이름 밑에 표시하는 텍스트를 수정하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
... // 생략
  Widget build(BuildContext context) {
    // 추가
    final _mstate_currentuser_len =
        context.watch<MessageListPrState>().checkUserMsg(widget.user.id).length; 
    bool msgtext_null = _mstate_currentuser_len == 0; 
    widget.user.messageText = msgtext_null
        ? " "
        : _mstate_currentuser[_mstate_currentuser_len - 1].messageContent;
    // 추가 END
    return GestureDetector(
      onTap: () async {
        await Navigator.of(context).push(MaterialPageRoute(
          builder: (context) {
            return ChatDetailPage(
              cpuser: widget.user,
            );
          },
        ));
        // 추가
        if (mounted) {
          setState(() {
          });
        }
        // 추가 END
      },
      child: Container(
        padding: EdgeInsets.only(left: 16, right: 16, top: 10, bottom: 10),
        child: Row(
          children: <Widget>[
            Expanded(
              child: Row(
                children: <Widget>[
                  InkWell(
                    ... // 생략
                  ),
                  Expanded(
                    child: Container(
                      color: Colors.transparent,
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(
                            widget.user.name,
                            style: TextStyle(fontSize: 16),
                          ),
                          SizedBox(
                            height: 6,
                          ),
                          Text(
                            widget.user.messageText, // <- 여기에 나타다는 글씨를 수정
                            style: TextStyle(
                                fontSize: 13,
                                color: Colors.grey.shade600,
                                fontWeight: widget.isMessageRead
                                    ? FontWeight.bold
                                    : FontWeight.normal),
                          ),
                        ],
                      ),
                    ),
                  ),
... // 생략

msgtext_null을 이용한 연산을 굳이 추가한 이유는, 유저를 생성했을 때 예외 처리가 필요하기 때문이다. 유저를 생성한 직 후라면 당연하지만 해당 유저와 관련된 메세지는 없을 것이다. 그러므로 해당 유저에 대한 메세지가 없을 때는, " "를 넣어 주어서 아무 것도 입력되지 않은 것 처럼 보여주도록 코드를 작성했다. 메세지가 있을 때는, 해당 유저와 나눈 메세지 리스트 중 가장 마지막에 있는 항목을 가져오도록 하면된다.

또한,ChatDetailPage에서 마지막 메세지가 수정된 경우에는 conversationList 위젯도 상태 갱신을 해주어야하기 때문에, onTap{}setState()를 추가해 주었다.

  • if (mounted) {} 조건문을 건 이유

상태 갱신을 위해서 setState()만을 추가해 주면, 이런 에러가 뜬다.

[VERBOSE-2:ui_dart_state.cc(198)] Unhandled Exception: setState() called after dispose(): _ConversationListState#8ffd2(lifecycle state: defunct, not mounted)
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree (e.g., whose parent widget no longer includes the widget in its build). This error can occur when code calls setState() from a timer or an animation callback.
The preferred solution is to cancel the timer or stop listening to the animation in the dispose() callback. Another solution is to check the "mounted" property of this object before calling setState() to ensure the object is still in the tree.
This error might indicate a memory leak if setState() is being called because another object is retaining a reference to this State object after it has been removed from the tree. To avoid memory leaks, consider breaking the reference to this object during dispose().
#0      State.setState.<anonymous closure> (package:flutter/src/widgets<…>

내용을 보면 아마 onTap{}에서 비동기 처리 중에 상태 갱신을 위해 setState()를 실행 했기 때문에 예외가 발생한 것 같다. 에러에서 Another solution is to check the "mounted" property of this object before calling setState() to ensure the object is still in the tree.라고 해결책을 주었길래, mounted를 체크하는 조건문을 추가해 주었더니 에러는 사라졌다. 당장 에러는 사라졌지만 사실 여전히 문제는 남아있고, 더 좋은 해결 방법이 있을 거다… 일단 보류하고 수정하면 포스팅을 업데이트 할 예정.

#수정 보류중인 오류
메세지를 입력한 후 ChatPage로 돌아오지 않고 ChatDetailPage에서 바로 마지막 메세지를 수정하는 후, ChatPage로 돌아오는 경우 수정 전의 메세지가 표시 된다. onTap의 비동기 처리로 인해 provider에서 데이터가 업데이트 되기 전에 ChatPage의 상태 갱신이 먼저 이루어지기 때문으로 보인다.

완성된 화면

Flutter Simple chat UI ex 03-06


다음 포스팅 내용

기본기능은 얼추 다 넣은 것 같은데,

  • provider/hive 사용 방식 개선
  • 수정 보류 중인 오류들 개선
  • 재밌어 보이는 플러그 인이 있으면 적용
  • 진짜 메세지를 주고 받을 수 있도록

혹시 새 프로젝트를 짠다면

  • 구체적으로 뭘 만들기 정하기
  • UX/UI 공부를 해서 좀더 사용자 친화적인 프로그램 만들기 를 해보고 싶다.

UI에 대해서는 미적감각이 거의 없다 싶은 사람이고 UX/UI는 관련지식이 1도 없기 때문에, 이론 공부부터 해야한다. 공부하면 어떻게든 되겠지 허허


후기

음… 공부를 더 하도록… 이런식의 얼레벌레 코드를 어따 써

This post is licensed under CC BY 4.0 by the author.